diff --git a/autostart/autostart.target.j2 b/autostart/autostart.target.j2 index 9a1aeae..11db5f9 100644 --- a/autostart/autostart.target.j2 +++ b/autostart/autostart.target.j2 @@ -30,3 +30,4 @@ Wants=xresources.service Wants=yubikey-touch-detector.service Wants=kdeconnect.service Wants=color-theme-dark.service +Wants=workstation-mgr.service diff --git a/autostart/services/workstation-mgr.service b/autostart/services/workstation-mgr.service new file mode 100644 index 0000000..c8ced7e --- /dev/null +++ b/autostart/services/workstation-mgr.service @@ -0,0 +1,8 @@ +[Unit] +BindsTo=autostart.target +After=windowmanager.target + +[Service] +Type=simple +ExecStart=/usr/bin/workstation-mgr serve +Restart=always diff --git a/i3/config.j2 b/i3/config.j2 index 1ba2b2d..c7b43da 100644 --- a/i3/config.j2 +++ b/i3/config.j2 @@ -209,18 +209,13 @@ assign [class="^Wine$"] $workspace10 bindsym $mod+Return exec $terminal bindsym $mod+Shift+Return exec $calc - bindsym F1 exec --no-startup-id $scriptdir/shutdown-menu - bindsym F2 exec --no-startup-id $scriptdir/screenmenu + bindsym F1 exec --no-startup-id workstation-client power menu - bindsym $mod+F1 exec --no-startup-id $scriptdir/i3exit lock - bindsym $mod+F4 exec --no-startup-id $scriptdir/i3exit suspend - bindsym $mod+Home exec --no-startup-id $scriptdir/shutdown-menu + bindsym $mod+F1 exec --no-startup-id workstation-client power lock bindsym $mod+$screenshot exec --no-startup-id sh -c 'maim | xclip -selection clipboard -t image/png' bindsym $mod+Shift+$screenshot exec --no-startup-id sh -c 'maim --select | xclip -selection clipboard -t image/png' - bindsym $mod+Shift+v exec --no-startup-id redshift-toggle - bindsym $mod+$pim_toggle exec --no-startup-id $scriptdir/swap-from-workspace $workspace10 ################################################################################ @@ -313,22 +308,22 @@ bindsym $mod+F9 exec --no-startup-id evolution ### SPECIAL KEYBINDS ########################################################### ################################################################################ -bindsym XF86Sleep exec --no-startup-id $scriptdir/i3exit suspend -bindsym XF86AudioMute exec --no-startup-id pactl set-sink-mute '@DEFAULT_SINK@' toggle -bindsym XF86AudioRaiseVolume exec --no-startup-id pactl set-sink-volume '@DEFAULT_SINK@' +5% -bindsym XF86AudioLowerVolume exec --no-startup-id pactl set-sink-volume '@DEFAULT_SINK@' -5% +bindsym XF86Sleep exec --no-startup-id workstation-client power lock -bindsym XF86AudioPlay exec --no-startup-id playerctl -p spotify play-pause -bindsym XF86AudioNext exec --no-startup-id playerctl -p spotify next -bindsym XF86AudioPrev exec --no-startup-id playerctl -p spotify previous +bindsym XF86AudioMute exec --no-startup-id workstation-client pulseaudio output toggle +bindsym XF86AudioRaiseVolume exec --no-startup-id workstation-client pulseaudio output inc +bindsym XF86AudioLowerVolume exec --no-startup-id workstation-client pulseaudio output dec -# keys seemingly switched -bindsym XF86MonBrightnessUp exec --no-startup-id brightnessctl set 8%+ ; exec --no-startup-id $scriptdir/update-status -bindsym XF86MonBrightnessDown exec --no-startup-id brightnessctl set 8%- ; exec --no-startup-id $scriptdir/update-status +bindsym XF86AudioPlay exec --no-startup-id workstation-client spotify toggle +bindsym XF86AudioNext exec --no-startup-id workstation-client spotify next +bindsym XF86AudioPrev exec --no-startup-id workstation-client spotify previous -bindsym $mod+m exec --no-startup-id pactl set-source-mute '@DEFAULT_SOURCE@' toggle -bindsym $mod+space exec --no-startup-id pactl set-source-mute '@DEFAULT_SOURCE@' toggle -bindsym KP_Enter exec --no-startup-id pactl set-source-mute '@DEFAULT_SOURCE@' toggle +bindsym XF86MonBrightnessUp exec --no-startup-id workstation-client brightness inc +bindsym XF86MonBrightnessDown exec --no-startup-id workstation-client brightness dec + +bindsym $mod+m exec --no-startup-id workstation-client pulseaudio input toggle +bindsym $mod+space exec --no-startup-id workstation-client pulseaudio input toggle +bindsym KP_Enter exec --no-startup-id workstation-client pulseaudio input toggle ############################################################################## ### BARS ####################################################################### diff --git a/i3/i3status-rust/config.toml.j2 b/i3/i3status-rust/config.toml.j2 index 631edfd..7a37eb3 100644 --- a/i3/i3status-rust/config.toml.j2 +++ b/i3/i3status-rust/config.toml.j2 @@ -33,7 +33,7 @@ format = " $icon{ $volume.eng(w:2)|} " [[block.click]] button = "left" -cmd = "pactl set-sink-mute '@DEFAULT_SINK@' toggle" +cmd = "workstation-client pulseaudio output toggle" update = true [[block]] @@ -50,7 +50,7 @@ idle_bg = { link = "warning_bg" } [[block.click]] button = "left" -cmd = "pactl set-source-mute '@DEFAULT_SOURCE@' toggle" +cmd = "workstation-client pulseaudio input toggle" update = true [[block]] @@ -67,31 +67,32 @@ missing_format = "" [[block]] block = "toggle" format = "  $icon " -command_on = "$XDG_CONFIG_HOME/i3/scripts/presentation-mode toggle ; pkill -SIGRTMIN+0 i3status-rs" -command_off = "$XDG_CONFIG_HOME/i3/scripts/presentation-mode toggle ; pkill -SIGRTMIN+0 i3status-rs" -command_state = "[[ $($XDG_CONFIG_HOME/i3/scripts/presentation-mode status) == on ]] && echo active" +signal = 1 +command_on = "workstation-client present toggle ; pkill -SIGRTMIN+1 i3status-rs" +command_off = "workstation-client present toggle ; pkill -SIGRTMIN+1 i3status-rs" +command_state = "[[ $(workstation-client present status) == on ]] && echo active" [[block]] block = "toggle" format = "  $icon " -command_on = "systemctl --user start color-theme-light" -command_off = "systemctl --user start color-theme-dark" -command_state = "[[ $(systemctl --user is-active color-theme-light) == active ]] && echo active" +command_on = "workstation-client theme light" +command_off = "workstation-client theme dark" +command_state = "[[ $(workstation-client theme status) == light ]] && echo 1" [[block]] block = "toggle" format = "  $icon " -command_on = "systemctl --user start redshift" -command_off = "systemctl --user stop redshift" -command_state = "[[ $(systemctl --user is-active redshift) == active ]] && echo active" +command_on = "workstation-client redshift start" +command_off = "workstation-client redshift stop" +command_state = "[[ $(workstation-client redshift status) == active ]] && echo 1" signal = 0 [[block]] block = "toggle" format = "  $icon " -command_on = "systemctl --user start spotify" -command_off = "systemctl --user stop spotify" -command_state = "[[ $(systemctl --user is-active spotify) == active ]] && echo active" +command_on = "workstation-client spotify start" +command_off = "workstation-client spotify stop" +command_state = "[[ $(workstation-client spotify status) == active ]] && echo 1" signal = 0 [[block]] @@ -101,8 +102,9 @@ command = "ping -n -q -w 2 -c 1 8.8.8.8 >/dev/null 2>/dev/null && printf '{\"tex [[block]] block = "custom" -command = "curl -s 'https://wttr.in/Ansbach?m&T&format=%c%t' | sed 's/ / /g'" -interval = 3600 +command = "workstation-client weather get" +# caching is handled by the workstation daemon +interval = 60 [[block]] block = "time" diff --git a/i3/scripts/appmenu b/i3/scripts/appmenu deleted file mode 100755 index 1283259..0000000 --- a/i3/scripts/appmenu +++ /dev/null @@ -1,2 +0,0 @@ -#!/usr/bin/env bash -rofi -show combi -combi-modi run -display-combi "run" diff --git a/i3/scripts/i3exit b/i3/scripts/i3exit deleted file mode 100755 index 08ec83f..0000000 --- a/i3/scripts/i3exit +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -### From http://www.archlinux.org/index.php/i3 - -_logfile="$XDG_RUNTIME_DIR/i3exit.log" - -touch "$_logfile" - -log() -{ - echo "$*" - echo "[$(date +%FT%T)] $*" >> "$_logfile" -} - -lock() -{ - set -x - playerctl -p spotify pause - - i3lock --nofork --show-failed-attempts --ignore-empty-password \ - --color "000000" -} - -screen_off() { - xset dpms force off -} - -reset_screen() { - systemctl --user restart dpms.service -} - -lock_and_screen_off() { - lock & - _pid=$! - dunst_paused=$(dunstctl is-paused) - [[ "${dunst_paused}" != "true" ]] && dunstctl set-paused true - screen_off - wait $_pid - [[ "${dunst_paused}" != "true" ]] && dunstctl set-paused false - reset_screen -} - -signal="$1" -log "[I] Received signal \"$signal\"." - -case "$signal" in - lock) - log "[I] Locking session." - lock_and_screen_off - ;; - logout) - log "[I] Exiting i3." - i3-msg exit - ;; - suspend) - log "[I] Suspending." - lock & - sleep 0.1 - systemctl suspend - ;; - hibernate) - log "[I] Hibernating." - sudo systemctl hibernate - ;; - reboot) - log "[I] Rebooting." - systemctl reboot - ;; - shutdown) - log "[I] Shutting down." - systemctl poweroff - ;; - screen-off) - log "[I] Turning screen off." - screen_off - ;; - *) - echo "Usage: $0 {lock|logout|suspend|hibernate|reboot|shutdown}" - log "[E] Signal \"$signal\" unknown. Aborting." - exit 2 -esac - -log "[I] Done." -exit 0 diff --git a/i3/scripts/presentation-mode b/i3/scripts/presentation-mode deleted file mode 100755 index 1b78a2c..0000000 --- a/i3/scripts/presentation-mode +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -_status_file="${XDG_RUNTIME_DIR}/presentation-mode-on" - -is_on() { - [[ -e "${_status_file}" ]] -} - -switch_on() { - touch "${_status_file}" - dunstctl set-paused true & - systemctl --user --no-block stop redshift.service - systemctl --user --no-block stop spotify.service -} - -switch_off() { - rm -f "${_status_file}" - dunstctl set-paused false & - systemctl --user --no-block start redshift.service - systemctl --user --no-block start spotify.service -} - - -case "$1" in - status) - if is_on ; then - printf "on\n" - else - printf "off\n" - fi - ;; - toggle) - if is_on ; then - switch_off - else - switch_on - fi - ;; - off) - switch_off - ;; - on) - switch_on - ;; -esac - - diff --git a/i3/scripts/shutdown-menu b/i3/scripts/shutdown-menu deleted file mode 100755 index d95492f..0000000 --- a/i3/scripts/shutdown-menu +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -options=( -"lock" -"logout" -"suspend" -"hibernate" -"reboot" -"shutdown" -"screen-off") - -i=1 -output=$( -for option in "${options[@]}"; do - echo "($i) $option" - (( i++ )) -done | rofi -dmenu -p "action" -no-custom) - -[[ "$output" ]] && "$(dirname "$0")"/i3exit "${output#(*) }" diff --git a/mgr/.gitignore b/mgr/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/mgr/.gitignore @@ -0,0 +1 @@ +/target diff --git a/mgr/Cargo.lock b/mgr/Cargo.lock new file mode 100644 index 0000000..b40ae62 --- /dev/null +++ b/mgr/Cargo.lock @@ -0,0 +1,503 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590f9024a68a8c40351881787f1934dc11afd69090f5edb6831464694d836ea3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "deranged" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d630bccd429a5bb5a64b5e94f693bfc48c9f8566418fda4c494cc94f911f87cc" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e178e4fba8a2726903f6ba98a6d221e76f9c12c650d5dc0e6afdc50677b49650" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "mgr" +version = "0.1.0" +dependencies = [ + "thiserror", + "time", + "tracing", + "tracing-subscriber", + "ureq", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +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 = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "untrusted", + "windows-sys", +] + +[[package]] +name = "rustls" +version = "0.23.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ebcbd2f03de0fc1122ad9bb24b127a5a6cd51d72604a3f3c50ac459762b6cc" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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 = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bde6f1ec10e72d583d91623c939f623002284ef622b87de38cfd546cbf2031" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00432f493971db5d8e47a65aeb3b02f8226b9b11f1450ff86bb772776ebadd70" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbe120bb823a0061680e66e9075942fcdba06d46551548c2c259766b9558bc9a" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +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" + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/mgr/Cargo.toml b/mgr/Cargo.toml new file mode 100644 index 0000000..68ff6fb --- /dev/null +++ b/mgr/Cargo.toml @@ -0,0 +1,184 @@ +[package] +name = "mgr" +version = "0.1.0" +edition = "2024" + +[dependencies] +thiserror = { version = "2.0.16", default-features = false } +time = { version = "0.3.43", default-features = false, features = ["formatting", "parsing", "std"] } +tracing = { version = "0.1.41", default-features = false } +tracing-subscriber = { version = "0.3.20", default-features = false, features = ["fmt"] } +ureq = { version = "3.1.0", default-features = false, features = ["rustls"] } + +[[bin]] +name = "workstation-mgr" +path = "src/bin/main.rs" + +[[bin]] +name = "workstation-client" +path = "src/bin/client.rs" + +[profile.release] +strip = true +lto = true +codegen-units = 1 +panic = "abort" + +[lints.clippy] +# enabled groups +correctness = { level = "deny", priority = -1 } +suspicious = { level = "warn", priority = -1 } +style = { level = "warn", priority = -1 } +complexity = { level = "warn", priority = -1 } +perf = { level = "warn", priority = -1 } +cargo = { level = "warn", priority = -1 } +pedantic = { level = "warn", priority = -1 } +nursery = { level = "warn", priority = -1 } + +# pedantic overrides +too_many_lines = "allow" +must_use_candidate = "allow" +map_unwrap_or = "allow" +missing_errors_doc = "allow" +if_not_else = "allow" +similar_names = "allow" +redundant_else = "allow" + +# nursery overrides +missing_const_for_fn = "allow" +option_if_let_else = "allow" +redundant_pub_crate = "allow" + +# complexity overrides +too_many_arguments = "allow" + +# style overrides +new_without_default = "allow" +redundant_closure = "allow" + +# cargo overrides +multiple_crate_versions = "allow" +cargo_common_metadata = "allow" + +# selected restrictions +allow_attributes = "warn" +allow_attributes_without_reason = "warn" +arithmetic_side_effects = "warn" +as_conversions = "warn" +assertions_on_result_states = "warn" +cfg_not_test = "warn" +clone_on_ref_ptr = "warn" +create_dir = "warn" +dbg_macro = "warn" +decimal_literal_representation = "warn" +default_numeric_fallback = "warn" +deref_by_slicing = "warn" +disallowed_script_idents = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +empty_structs_with_brackets = "warn" +exit = "warn" +filetype_is_file = "warn" +float_arithmetic = "warn" +float_cmp_const = "warn" +fn_to_numeric_cast_any = "warn" +format_push_string = "warn" +get_unwrap = "warn" +indexing_slicing = "warn" +infinite_loop = "warn" +inline_asm_x86_att_syntax = "warn" +inline_asm_x86_intel_syntax = "warn" +integer_division = "warn" +iter_over_hash_type = "warn" +large_include_file = "warn" +let_underscore_must_use = "warn" +let_underscore_untyped = "warn" +little_endian_bytes = "warn" +lossy_float_literal = "warn" +map_err_ignore = "warn" +mem_forget = "warn" +missing_assert_message = "warn" +missing_asserts_for_indexing = "warn" +mixed_read_write_in_expression = "warn" +modulo_arithmetic = "warn" +multiple_inherent_impl = "warn" +multiple_unsafe_ops_per_block = "warn" +mutex_atomic = "warn" +panic = "warn" +partial_pub_fields = "warn" +pattern_type_mismatch = "warn" +print_stderr = "warn" +print_stdout = "warn" +pub_without_shorthand = "warn" +rc_buffer = "warn" +rc_mutex = "warn" +redundant_type_annotations = "warn" +renamed_function_params = "warn" +rest_pat_in_fully_bound_structs = "warn" +same_name_method = "warn" +self_named_module_files = "warn" +semicolon_inside_block = "warn" +str_to_string = "warn" +string_add = "warn" +string_lit_chars_any = "warn" +string_slice = "warn" +implicit_clone = "warn" +suspicious_xor_used_as_pow = "warn" +tests_outside_test_module = "warn" +todo = "warn" +try_err = "warn" +undocumented_unsafe_blocks = "warn" +unimplemented = "warn" +unnecessary_safety_comment = "warn" +unnecessary_safety_doc = "warn" +unnecessary_self_imports = "warn" +unneeded_field_pattern = "warn" +unseparated_literal_suffix = "warn" +unused_result_ok = "warn" +unwrap_used = "warn" +use_debug = "warn" +verbose_file_reads = "warn" + +# restrictions explicit allows +absolute_paths = "allow" +alloc_instead_of_core = "allow" +as_underscore = "allow" +big_endian_bytes = "allow" +default_union_representation = "allow" +error_impl_error = "allow" +exhaustive_enums = "allow" +exhaustive_structs = "allow" +expect_used = "allow" +field_scoped_visibility_modifiers = "allow" +host_endian_bytes = "allow" +if_then_some_else_none = "allow" +impl_trait_in_params = "allow" +implicit_return = "allow" +integer_division_remainder_used = "allow" +min_ident_chars = "allow" +missing_docs_in_private_items = "allow" +missing_inline_in_public_items = "allow" +missing_trait_methods = "allow" +mod_module_files = "allow" +needless_raw_strings = "allow" +non_ascii_literal = "allow" +non_zero_suggestions = "allow" +panic_in_result_fn = "allow" +pathbuf_init_then_push = "allow" +pub_use = "allow" +pub_with_shorthand = "allow" +question_mark_used = "allow" +ref_patterns = "allow" +semicolon_outside_block = "allow" +separated_literal_suffix = "allow" +shadow_reuse = "allow" +shadow_same = "allow" +shadow_unrelated = "allow" +single_call_fn = "allow" +single_char_lifetime_names = "allow" +std_instead_of_alloc = "allow" +std_instead_of_core = "allow" +unreachable = "allow" +unused_trait_names = "allow" +unwrap_in_result = "allow" +wildcard_enum_match_arm = "allow" diff --git a/mgr/src/bin/client.rs b/mgr/src/bin/client.rs new file mode 100644 index 0000000..08b0da4 --- /dev/null +++ b/mgr/src/bin/client.rs @@ -0,0 +1,87 @@ +#![expect( + clippy::print_stderr, + clippy::print_stdout, + reason = "output is fine for cli" +)] + +use std::{ + env, + io::{self, Read as _}, + net, + os::unix::net::UnixStream, + process, str, +}; + +use thiserror::Error; + +use mgr::{ + Action, + cli::{self, CliCommand as _, ParseError}, + wire::{client, socket}, +}; + +#[derive(Debug, Error)] +enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Socket(#[from] socket::Error), + #[error(transparent)] + Send(#[from] client::SendError), + #[error(transparent)] + CliParse(#[from] cli::ParseError), + #[error("response is not valid utf8: {0}")] + ResponseNonUtf8(#[from] str::Utf8Error), +} + +enum MainResult { + Success, + Failure(Error), +} + +impl process::Termination for MainResult { + fn report(self) -> process::ExitCode { + match self { + Self::Success => process::ExitCode::SUCCESS, + Self::Failure(e) => { + eprintln!("Error: {e}"); + process::ExitCode::FAILURE + } + } + } +} + +fn main() -> MainResult { + fn inner() -> Result<(), Error> { + let mut args = env::args().skip(1); + + let socket = socket::get_socket_path()?; + let mut stream = UnixStream::connect(socket)?; + + let action = + Action::parse_str(args.next().ok_or(ParseError::MissingAction)?.as_str(), args)?; + + action.send(&mut stream)?; + + stream.shutdown(net::Shutdown::Write)?; + + let response = { + let mut buf = Vec::new(); + stream.read_to_end(&mut buf)?; + let response = str::from_utf8(&buf)?.to_owned(); + drop(stream); + response + }; + + if !response.is_empty() { + println!("{response}"); + } + + Ok(()) + } + + match inner() { + Ok(()) => MainResult::Success, + Err(e) => MainResult::Failure(e), + } +} diff --git a/mgr/src/bin/main.rs b/mgr/src/bin/main.rs new file mode 100755 index 0000000..79c084b --- /dev/null +++ b/mgr/src/bin/main.rs @@ -0,0 +1,105 @@ +#![expect( + clippy::print_stderr, + clippy::print_stdout, + reason = "output is fine for cli" +)] + +use std::{env, process}; + +use thiserror::Error; +use tracing::Level; + +use mgr::{ + self, Action, Exec as _, + cli::{CliCommand as _, ParseError}, +}; + +#[derive(Debug, Error)] +enum Error { + #[error(transparent)] + Power(#[from] mgr::power::Error), + #[error(transparent)] + Dmenu(#[from] mgr::dmenu::Error), + #[error(transparent)] + Server(#[from] mgr::wire::server::Error), + #[error(transparent)] + Presentation(#[from] mgr::present::Error), + #[error(transparent)] + Exec(#[from] mgr::ExecError), + #[error(transparent)] + ParseParse(#[from] ParseError), + #[error(transparent)] + Tracing(#[from] tracing::dispatcher::SetGlobalDefaultError), +} + +enum MainResult { + Success, + Failure(Error), +} + +impl process::Termination for MainResult { + fn report(self) -> process::ExitCode { + match self { + Self::Success => process::ExitCode::SUCCESS, + Self::Failure(e) => { + eprintln!("Error: {e}"); + process::ExitCode::FAILURE + } + } + } +} + +impl From for MainResult { + fn from(value: Error) -> Self { + Self::Failure(value) + } +} + +fn init_tracing() -> Result<(), Error> { + tracing::subscriber::set_global_default( + tracing_subscriber::fmt() + .with_max_level(Level::DEBUG) + .event_format( + tracing_subscriber::fmt::format() + .with_ansi(false) + .with_target(false) + .compact(), + ) + .finish(), + )?; + Ok(()) +} + +fn main() -> MainResult { + fn inner() -> Result<(), Error> { + init_tracing()?; + + let mut args = env::args().skip(1); + + match args.next().ok_or(ParseError::MissingAction)?.as_str() { + "serve" => { + mgr::wire::server::run()?; + Ok(()) + } + "run" => { + let action = Action::parse_str( + args.next().ok_or(ParseError::MissingAction)?.as_str(), + args, + )?; + if let Some(output) = action.execute()? { + println!("{output}"); + } + Ok(()) + } + input => Err(ParseError::UnknownAction { + action: input.to_owned(), + } + .into()), + } + } + + match inner() { + Ok(()) => MainResult::Success, + Err(e) => MainResult::Failure(e), + } +} diff --git a/mgr/src/brightness.rs b/mgr/src/brightness.rs new file mode 100644 index 0000000..f11336c --- /dev/null +++ b/mgr/src/brightness.rs @@ -0,0 +1,77 @@ +use thiserror::Error; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Cmd(#[from] cmd::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + Inc, + Dec, +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::Inc), + 0x02 => Ok(Self::Dec), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::Inc => vec![0x01], + Self::Dec => vec![0x02], + } + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized, + { + let result = match input { + "inc" => Self::Inc, + "dec" => Self::Dec, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::Inc => cmd::command("brightnessctl", &["set", "8%+"])?, + Self::Dec => cmd::command("brightnessctl", &["set", "8%-"])?, + } + Ok(None) + } +} diff --git a/mgr/src/cli.rs b/mgr/src/cli.rs new file mode 100644 index 0000000..988217c --- /dev/null +++ b/mgr/src/cli.rs @@ -0,0 +1,19 @@ +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ParseError { + #[error("no action given")] + MissingAction, + #[error("unknown action: {action}")] + UnknownAction { action: String }, + #[error("unexpected input: {rest:?}")] + UnexpectedInput { rest: Vec }, +} + +pub trait CliCommand { + type ExecErr: From; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized; +} diff --git a/mgr/src/cmd.rs b/mgr/src/cmd.rs new file mode 100644 index 0000000..e57d6e2 --- /dev/null +++ b/mgr/src/cmd.rs @@ -0,0 +1,183 @@ +use std::{io, panic, process, str, thread}; + +use thiserror::Error; +use tracing::{Level, event}; + +#[derive(Error, Debug)] +pub enum Error { + #[error("command \"{command}\" failed: {error}")] + CommandInvocation { + command: &'static str, + error: io::Error, + }, + #[error("command \"{command}\" was terminated by signal")] + CommandTerminatedBySignal { command: &'static str }, + #[error( + "command \"{command}\" failed [{code}]: {stderr}", + code = match *.code { + Some(code) => &.code.to_string(), + _ => "unknown exit code", + }, + stderr = if .stderr.is_empty() { + "[stderr empty]" + } else { + .stderr + })] + CommandFailed { + command: &'static str, + code: Option, + stderr: String, + }, + #[error("{command} produced non-utf8 output: {error}")] + CommandOutputNonUtf8 { + command: &'static str, + error: str::Utf8Error, + }, + #[error("failed writing to stdin of command \"{command}\": {error}")] + StdinWriteFailed { + command: &'static str, + error: io::Error, + }, +} + +pub(crate) fn command(command: &'static str, args: &[&str]) -> Result<(), Error> { + let _: FinishedProcess = run_command_checked(command, args)?; + Ok(()) +} + +pub(crate) fn run_command(command: &'static str, args: &[&str]) -> Result { + event!(Level::DEBUG, "running {command} {args:?}"); + let proc = process::Command::new(command) + .args(args) + .output() + .map_err(|error| Error::CommandInvocation { command, error })?; + + Ok(FinishedProcess { + exit_code: proc + .status + .code() + .ok_or(Error::CommandTerminatedBySignal { command })?, + stdout: str::from_utf8(&proc.stdout) + .map_err(|error| Error::CommandOutputNonUtf8 { command, error })? + .to_owned(), + stderr: str::from_utf8(&proc.stderr) + .map_err(|error| Error::CommandOutputNonUtf8 { command, error })? + .to_owned(), + }) +} + +pub(crate) fn run_command_checked( + command: &'static str, + args: &[&str], +) -> Result { + let output = run_command(command, args)?; + + if output.exit_code != 0_i32 { + event!(Level::DEBUG, "{command} {args:?} failed"); + return Err(Error::CommandFailed { + command, + code: Some(output.exit_code), + stderr: output.stderr, + }); + } + + Ok(output) +} + +pub(crate) fn command_output(command: &'static str, args: &[&str]) -> Result { + let output = run_command_checked(command, args)?; + Ok(output.stdout) +} + +#[derive(Debug)] +pub(crate) struct FinishedProcess { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +pub(crate) fn command_output_with_stdin_write( + command: &'static str, + args: &[&str], + input: &[u8], +) -> Result { + use io::Write as _; + + let process = process::Command::new(command) + .args(args) + .stdin(process::Stdio::piped()) + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::null()) + .spawn() + .map_err(|error| Error::CommandInvocation { command, error })?; + + let mut stdin = process + .stdin + .as_ref() + .expect("stdin handle must be present"); + + stdin + .write_all(input) + .map_err(|error| Error::StdinWriteFailed { command, error })?; + + let output = process + .wait_with_output() + .map_err(|error| Error::CommandInvocation { command, error })?; + + let exit_code = output + .status + .code() + .ok_or(Error::CommandTerminatedBySignal { command })?; + + let stdout = str::from_utf8(&output.stdout) + .map_err(|error| Error::CommandOutputNonUtf8 { command, error })? + .to_owned(); + + let stderr = str::from_utf8(&output.stderr) + .map_err(|error| Error::CommandOutputNonUtf8 { command, error })? + .to_owned(); + + Ok(FinishedProcess { + exit_code, + stdout, + stderr, + }) +} + +pub(crate) struct RunningProcess { + command: &'static str, + join_handle: thread::JoinHandle>, +} + +impl RunningProcess { + pub fn with Result<(), E>, E: From>( + self, + f: F, + ) -> Result { + f()?; + event!( + Level::DEBUG, + "waiting for process {} to finish", + self.command + ); + let ret = match self.join_handle.join() { + Ok(ret) => ret?, + Err(e) => panic::resume_unwind(e), + }; + event!(Level::DEBUG, "process {} finished", self.command); + Ok(ret) + } +} + +pub(crate) fn start_command( + command: &'static str, + args: &'static [&'static str], +) -> RunningProcess { + event!(Level::DEBUG, "starting {command} {args:?}"); + let join_handle = thread::spawn(move || run_command_checked(command, args)); + + RunningProcess { + command, + join_handle, + } +} diff --git a/mgr/src/dirs.rs b/mgr/src/dirs.rs new file mode 100644 index 0000000..9a7ad1f --- /dev/null +++ b/mgr/src/dirs.rs @@ -0,0 +1,29 @@ +use std::path::PathBuf; + +use thiserror::Error; + +use super::env; + +const ENV_XDG_RUNTIME_DIR: &str = "XDG_RUNTIME_DIR"; +const ENV_XDG_CACHE_DIR: &str = "XDG_CACHE_HOME"; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Env(#[from] env::Error), +} + +pub(crate) fn xdg_runtime_dir() -> Result, Error> { + Ok(env::get(ENV_XDG_RUNTIME_DIR)?.map(PathBuf::from)) +} + +pub(crate) fn require_xdg_runtime_dir() -> Result { + Ok(PathBuf::from(env::require(ENV_XDG_RUNTIME_DIR)?)) +} + +pub(crate) fn xdg_cache_dir() -> Result { + Ok(match env::get(ENV_XDG_CACHE_DIR)? { + Some(value) => PathBuf::from(value), + None => PathBuf::from(env::require("HOME")?).join(".cache"), + }) +} diff --git a/mgr/src/dmenu.rs b/mgr/src/dmenu.rs new file mode 100644 index 0000000..b06bc87 --- /dev/null +++ b/mgr/src/dmenu.rs @@ -0,0 +1,66 @@ +use std::{fmt::Write as _, num}; + +use thiserror::Error; +use tracing::{Level, event}; + +use super::cmd; + +#[derive(Debug, Error)] +pub enum Error { + #[error("rofi did not return an integer: {error}")] + RofiNonIntOutput { error: num::ParseIntError }, + #[error("rofi returned an invalid indexx: {index}")] + RofiInvalidIndex { index: usize }, + #[error(transparent)] + Cmd(#[from] cmd::Error), +} + +pub(crate) fn get_choice(actions: &[&'static str]) -> Result, Error> { + const ROFI: &str = "rofi"; + + event!(Level::DEBUG, "starting rofi"); + + let process = cmd::command_output_with_stdin_write( + ROFI, + &[ + "-dmenu", + "-p", + "action", + "-l", + &actions.len().to_string(), + "-no-custom", + "-sync", + "-format", + "i", + ], + actions + .iter() + .enumerate() + .fold(String::new(), |mut output, (i, action)| { + writeln!( + output, + "({i}) {action}", + i = i.checked_add(1).expect("too many action") + ) + .expect("writing to string cannot fail"); + output + }) + .as_bytes(), + )?; + + if process.exit_code == 1 { + Ok(None) + } else { + let choice = process + .stdout + .trim() + .parse::() + .map_err(|error| Error::RofiNonIntOutput { error })?; + + Ok(Some( + actions + .get(choice) + .ok_or(Error::RofiInvalidIndex { index: choice })?, + )) + } +} diff --git a/mgr/src/dunst.rs b/mgr/src/dunst.rs new file mode 100644 index 0000000..7fc74a1 --- /dev/null +++ b/mgr/src/dunst.rs @@ -0,0 +1,40 @@ +use thiserror::Error; + +use super::cmd; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error("dunstctl is-paused returned unknown output: {output}")] + DunstctlIsPausedUnknownOutput { output: String }, +} + +#[derive(Clone, Copy)] +pub(crate) enum Status { + Paused, + Unpaused, +} + +pub(crate) fn set_status(status: Status) -> Result<(), Error> { + Ok(cmd::command( + "dunstctl", + &[ + "set-paused", + match status { + Status::Paused => "true", + Status::Unpaused => "false", + }, + ], + )?) +} + +pub(crate) fn is_paused() -> Result { + let output = cmd::command_output("dunstctl", &["is-paused"])?; + + match output.trim() { + "true" => Ok(true), + "false" => Ok(false), + _ => Err(Error::DunstctlIsPausedUnknownOutput { output }), + } +} diff --git a/mgr/src/env.rs b/mgr/src/env.rs new file mode 100644 index 0000000..20b16ae --- /dev/null +++ b/mgr/src/env.rs @@ -0,0 +1,28 @@ +use std::{env, ffi::OsString}; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error( + "env variable \"{name}\" is not valid unicode: \"{value}\"", + value = value.to_string_lossy() + )] + EnvNotUnicode { name: &'static str, value: OsString }, + #[error("env variable \"{name}\" not found")] + EnvNotFound { name: &'static str }, +} + +pub(crate) fn get(var: &'static str) -> Result, Error> { + match env::var(var) { + Ok(value) => Ok(Some(value)), + Err(e) => match e { + env::VarError::NotPresent => Ok(None), + env::VarError::NotUnicode(value) => Err(Error::EnvNotUnicode { name: var, value }), + }, + } +} + +pub(crate) fn require(var: &'static str) -> Result { + get(var)?.ok_or(Error::EnvNotFound { name: var }) +} diff --git a/mgr/src/lib.rs b/mgr/src/lib.rs new file mode 100644 index 0000000..241474c --- /dev/null +++ b/mgr/src/lib.rs @@ -0,0 +1,190 @@ +use thiserror::Error; + +pub(crate) mod brightness; +pub mod cli; +pub(crate) mod cmd; +pub(crate) mod dirs; +pub mod dmenu; +pub(crate) mod dunst; +pub(crate) mod env; +pub mod power; +pub mod present; +pub(crate) mod pulseaudio; +pub(crate) mod redshift; +pub(crate) mod spotify; +pub(crate) mod systemd; +pub(crate) mod theme; +pub(crate) mod weather; +pub mod wire; + +#[derive(Debug, Error)] +pub enum ExecError { + #[error(transparent)] + Power(#[from] power::Error), + #[error(transparent)] + Presentation(#[from] present::Error), + #[error(transparent)] + Pulseaudio(#[from] pulseaudio::Error), + #[error(transparent)] + Theme(#[from] theme::Error), + #[error(transparent)] + Spotify(#[from] spotify::Error), + #[error(transparent)] + Redshift(#[from] redshift::Error), + #[error(transparent)] + Weather(#[from] weather::Error), + #[error(transparent)] + Brightness(#[from] brightness::Error), + #[error(transparent)] + Parse(#[from] cli::ParseError), +} + +#[derive(Debug)] +pub enum Action { + Power(power::Action), + Present(present::Action), + Pulseaudio(pulseaudio::Action), + Theme(theme::Action), + Spotify(spotify::Action), + Redshift(redshift::Action), + Weather(weather::Action), + Brightness(brightness::Action), +} + +impl wire::WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(wire::server::ParseError::Eof)? { + 0x01 => Ok(Self::Power(power::Action::parse_wire(input)?)), + 0x02 => Ok(Self::Present(present::Action::parse_wire(input)?)), + 0x03 => Ok(Self::Pulseaudio(pulseaudio::Action::parse_wire(input)?)), + 0x04 => Ok(Self::Theme(theme::Action::parse_wire(input)?)), + 0x05 => Ok(Self::Spotify(spotify::Action::parse_wire(input)?)), + 0x06 => Ok(Self::Redshift(redshift::Action::parse_wire(input)?)), + 0x07 => Ok(Self::Weather(weather::Action::parse_wire(input)?)), + 0x08 => Ok(Self::Brightness(brightness::Action::parse_wire(input)?)), + other => Err(wire::server::ParseError::Unknown(other)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::Power(action) => { + let mut v = vec![0x01]; + v.extend_from_slice(&action.to_wire()); + v + } + Self::Present(action) => { + let mut v = vec![0x02]; + v.extend_from_slice(&action.to_wire()); + v + } + Self::Pulseaudio(action) => { + let mut v = vec![0x03]; + v.extend_from_slice(&action.to_wire()); + v + } + Self::Theme(action) => { + let mut v = vec![0x04]; + v.extend_from_slice(&action.to_wire()); + v + } + Self::Spotify(action) => { + let mut v = vec![0x05]; + v.extend_from_slice(&action.to_wire()); + v + } + Self::Redshift(action) => { + let mut v = vec![0x06]; + v.extend_from_slice(&action.to_wire()); + v + } + Self::Weather(action) => { + let mut v = vec![0x07]; + v.extend_from_slice(&action.to_wire()); + v + } + Self::Brightness(action) => { + let mut v = vec![0x08]; + v.extend_from_slice(&action.to_wire()); + v + } + } + } +} + +impl cli::CliCommand for Action { + type ExecErr = ExecError; + + fn parse_str( + input: &str, + mut rest: impl Iterator, + ) -> Result + where + Self: Sized, + { + match input { + "power" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Power(power::Action::parse_str(&choice, rest)?)) + } + "present" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Present(present::Action::parse_str(&choice, rest)?)) + } + "pulseaudio" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Pulseaudio(pulseaudio::Action::parse_str( + &choice, rest, + )?)) + } + "theme" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Theme(theme::Action::parse_str(&choice, rest)?)) + } + "spotify" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Spotify(spotify::Action::parse_str(&choice, rest)?)) + } + "redshift" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Redshift(redshift::Action::parse_str(&choice, rest)?)) + } + "weather" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Weather(weather::Action::parse_str(&choice, rest)?)) + } + "brightness" => { + let choice = rest.next().ok_or(cli::ParseError::MissingAction)?; + Ok(Self::Brightness(brightness::Action::parse_str( + &choice, rest, + )?)) + } + s => Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }), + } + } +} + +pub trait Exec { + type ExecErr: Into; + + fn execute(&self) -> Result, Self::ExecErr>; +} + +impl Exec for Action { + type ExecErr = ExecError; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::Power(action) => Ok(action.execute()?), + Self::Present(action) => Ok(action.execute()?), + Self::Pulseaudio(action) => Ok(action.execute()?), + Self::Theme(action) => Ok(action.execute()?), + Self::Spotify(action) => Ok(action.execute()?), + Self::Redshift(action) => Ok(action.execute()?), + Self::Weather(action) => Ok(action.execute()?), + Self::Brightness(action) => Ok(action.execute()?), + } + } +} diff --git a/mgr/src/power.rs b/mgr/src/power.rs new file mode 100644 index 0000000..6a6cae1 --- /dev/null +++ b/mgr/src/power.rs @@ -0,0 +1,223 @@ +use thiserror::Error; +use tracing::{Level, event}; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, dmenu, dunst, spotify, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Dunst(#[from] dunst::Error), + #[error("unknown action: {action}")] + UnknownAction { action: String }, + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error(transparent)] + Dmenu(#[from] dmenu::Error), + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Spotify(#[from] spotify::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + Menu, + Lock, + Suspend, + Hibernate, + Reboot, + Poweroff, +} + +impl Action { + fn as_str(self) -> &'static str { + match self { + Self::Menu => "menu", + Self::Lock => "lock", + Self::Suspend => "suspend", + Self::Hibernate => "hibernate", + Self::Reboot => "reboot", + Self::Poweroff => "poweroff", + } + } +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::Menu), + 0x02 => Ok(Self::Lock), + 0x03 => Ok(Self::Suspend), + 0x04 => Ok(Self::Hibernate), + 0x05 => Ok(Self::Reboot), + 0x06 => Ok(Self::Poweroff), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::Menu => vec![0x01], + Self::Lock => vec![0x02], + Self::Suspend => vec![0x03], + Self::Hibernate => vec![0x04], + Self::Reboot => vec![0x05], + Self::Poweroff => vec![0x06], + } + } +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::Menu => menu()?, + Self::Lock => lock_and_screen_off()?, + Self::Suspend => lock_and_suspend()?, + Self::Hibernate => hibernate()?, + Self::Reboot => reboot()?, + Self::Poweroff => poweroff()?, + } + Ok(None) + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized, + { + let result = match input { + "menu" => Self::Menu, + "lock" => Self::Lock, + "suspend" => Self::Suspend, + "hibernate" => Self::Hibernate, + "reboot" => Self::Reboot, + "shutdown" => Self::Poweroff, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} + +const MENU_ACTIONS: &[Action] = &[ + Action::Lock, + Action::Suspend, + Action::Hibernate, + Action::Reboot, + Action::Poweroff, +]; + +fn menu() -> Result<(), Error> { + let choice = dmenu::get_choice( + &MENU_ACTIONS + .iter() + .map(|action| action.as_str()) + .collect::>(), + )?; + + if let Some(choice) = choice { + MENU_ACTIONS + .iter() + .find(|action| action.as_str() == choice) + .copied() + .expect("choice must be one of the valid values") + .execute()?; + } else { + event!(Level::DEBUG, "rofi was cancelled"); + } + + Ok(()) +} + +fn screen_off() -> Result<(), Error> { + Ok(cmd::command("xset", &["dpms", "force", "off"])?) +} + +fn lock() -> Result { + spotify::pause()?; + + let lock_handle = cmd::start_command( + "i3lock", + &[ + "--nofork", + "--show-failed-attempts", + "--ignore-empty-password", + "--color", + "000000", + ], + ); + + Ok(lock_handle) +} + +fn reset_screen() -> Result<(), Error> { + Ok(cmd::command( + "systemctl", + &["--user", "restart", "dpms.service"], + )?) +} + +fn lock_and_screen_off() -> Result<(), Error> { + let dunst_paused = dunst::is_paused()?; + if dunst_paused { + dunst::set_status(dunst::Status::Paused)?; + } + + lock()?.with(|| -> Result<(), Error> { + screen_off()?; + Ok(()) + })?; + + if dunst_paused { + dunst::set_status(dunst::Status::Unpaused)?; + } + + reset_screen()?; + + Ok(()) +} + +fn suspend() -> Result<(), Error> { + Ok(cmd::command("systemctl", &["suspend"])?) +} + +fn hibernate() -> Result<(), Error> { + Ok(cmd::command("systemctl", &["hibernate"])?) +} + +fn reboot() -> Result<(), Error> { + Ok(cmd::command("systemctl", &["reboot"])?) +} + +fn poweroff() -> Result<(), Error> { + Ok(cmd::command("systemctl", &["poweroff"])?) +} + +fn lock_and_suspend() -> Result<(), Error> { + lock()?.with(|| -> Result<(), Error> { + screen_off()?; + suspend()?; + Ok(()) + })?; + + Ok(()) +} diff --git a/mgr/src/present.rs b/mgr/src/present.rs new file mode 100644 index 0000000..f67ec19 --- /dev/null +++ b/mgr/src/present.rs @@ -0,0 +1,154 @@ +use std::{fs, io, path::PathBuf}; + +use thiserror::Error; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, dirs, dunst, redshift, spotify, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error("unknown action: {action}")] + UnknownAction { action: String }, + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Dirs(#[from] dirs::Error), + #[error(transparent)] + Dunst(#[from] dunst::Error), + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error(transparent)] + Redshift(#[from] redshift::Error), + #[error(transparent)] + Spotify(#[from] spotify::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + On, + Off, + Toggle, + Status, +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::On), + 0x02 => Ok(Self::Off), + 0x03 => Ok(Self::Toggle), + 0x04 => Ok(Self::Status), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::On => vec![0x01], + Self::Off => vec![0x02], + Self::Toggle => vec![0x03], + Self::Status => vec![0x04], + } + } +} + +fn status_file() -> Result { + Ok(dirs::require_xdg_runtime_dir()?.join("presentation-mode-on")) +} + +#[derive(Debug, Clone, Copy)] +enum Status { + On, + Off, +} + +fn status() -> Result { + Ok(if status_file()?.exists() { + Status::On + } else { + Status::Off + }) +} + +fn on() -> Result<(), Error> { + drop(fs::File::create(status_file()?)?); + dunst::set_status(dunst::Status::Paused)?; + redshift::set(redshift::Status::Off)?; + spotify::set(spotify::Status::Off)?; + Ok(()) +} + +fn off() -> Result<(), Error> { + fs::remove_file(status_file()?)?; + dunst::set_status(dunst::Status::Unpaused)?; + redshift::set(redshift::Status::On)?; + spotify::set(spotify::Status::On)?; + Ok(()) +} + +fn toggle() -> Result<(), Error> { + match status()? { + Status::On => off()?, + Status::Off => on()?, + } + Ok(()) +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::On => { + on()?; + Ok(None) + } + Self::Off => { + off()?; + Ok(None) + } + Self::Toggle => { + toggle()?; + Ok(None) + } + Self::Status => Ok(match status()? { + Status::On => Some("on".to_owned()), + Status::Off => Some("off".to_owned()), + }), + } + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized, + { + let result = match input { + "on" => Self::On, + "off" => Self::Off, + "toggle" => Self::Toggle, + "status" => Self::Status, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} diff --git a/mgr/src/pulseaudio.rs b/mgr/src/pulseaudio.rs new file mode 100644 index 0000000..11a173e --- /dev/null +++ b/mgr/src/pulseaudio.rs @@ -0,0 +1,112 @@ +use thiserror::Error; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Cmd(#[from] cmd::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + InputToggle, + OutputToggle, + OutputInc, + OutputDec, +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::InputToggle), + 0x02 => Ok(Self::OutputToggle), + 0x03 => Ok(Self::OutputInc), + 0x04 => Ok(Self::OutputDec), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::InputToggle => vec![0x01], + Self::OutputToggle => vec![0x02], + Self::OutputInc => vec![0x03], + Self::OutputDec => vec![0x04], + } + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str( + input: &str, + mut rest: impl Iterator, + ) -> Result + where + Self: Sized, + { + let result = match input { + "input" => match rest.next().ok_or(cli::ParseError::MissingAction)?.as_str() { + "toggle" => Self::InputToggle, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }, + "output" => match rest.next().ok_or(cli::ParseError::MissingAction)?.as_str() { + "toggle" => Self::OutputToggle, + "inc" => Self::OutputInc, + "dec" => Self::OutputDec, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::InputToggle => { + cmd::command("pactl", &["set-source-mute", "@DEFAULT_SOURCE@", "toggle"])?; + } + Self::OutputToggle => { + cmd::command("pactl", &["set-sink-mute", "@DEFAULT_SINK@", "toggle"])?; + } + Self::OutputInc => { + cmd::command("pactl", &["set-sink-volume", "@DEFAULT_SINK@", "+5%"])?; + } + Self::OutputDec => { + cmd::command("pactl", &["set-sink-volume", "@DEFAULT_SINK@", "-5%"])?; + } + } + Ok(None) + } +} diff --git a/mgr/src/redshift.rs b/mgr/src/redshift.rs new file mode 100644 index 0000000..4527a2b --- /dev/null +++ b/mgr/src/redshift.rs @@ -0,0 +1,116 @@ +use thiserror::Error; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, systemd, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Systemd(#[from] systemd::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + Start, + Stop, + Status, +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::Start), + 0x02 => Ok(Self::Stop), + 0x03 => Ok(Self::Status), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::Start => vec![0x01], + Self::Stop => vec![0x02], + Self::Status => vec![0x03], + } + } +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::Start => { + set(Status::On)?; + Ok(None) + } + Self::Stop => { + set(Status::Off)?; + Ok(None) + } + Self::Status => Ok( + if systemd::user::unit_status("redshift.service")?.is_active() { + Some("active".to_owned()) + } else { + Some("inactive".to_owned()) + }, + ), + } + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized, + { + let result = match input { + "start" => Self::Start, + "stop" => Self::Stop, + "status" => Self::Status, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Status { + On, + Off, +} + +pub(crate) fn set(status: Status) -> Result<(), Error> { + Ok(cmd::command( + "systemctl", + &[ + "--user", + "--no-block", + match status { + Status::On => "start", + Status::Off => "stop", + }, + "redshift.service", + ], + )?) +} diff --git a/mgr/src/spotify.rs b/mgr/src/spotify.rs new file mode 100644 index 0000000..8712494 --- /dev/null +++ b/mgr/src/spotify.rs @@ -0,0 +1,164 @@ +use thiserror::Error; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, systemd, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Systemd(#[from] systemd::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + Start, + Stop, + Status, + Play, + Pause, + Toggle, + Previous, + Next, +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::Start), + 0x02 => Ok(Self::Stop), + 0x03 => Ok(Self::Status), + 0x04 => Ok(Self::Play), + 0x05 => Ok(Self::Pause), + 0x06 => Ok(Self::Toggle), + 0x07 => Ok(Self::Previous), + 0x08 => Ok(Self::Next), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::Start => vec![0x01], + Self::Stop => vec![0x02], + Self::Status => vec![0x03], + Self::Play => vec![0x04], + Self::Pause => vec![0x05], + Self::Toggle => vec![0x06], + Self::Previous => vec![0x07], + Self::Next => vec![0x08], + } + } +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::Start => { + set(Status::On)?; + Ok(None) + } + Self::Stop => { + set(Status::Off)?; + Ok(None) + } + Self::Status => Ok( + if systemd::user::unit_status("spotify.service")?.is_active() { + Some("active".to_owned()) + } else { + Some("inactive".to_owned()) + }, + ), + Self::Play => { + playerctl("play")?; + Ok(None) + } + Self::Pause => { + playerctl("pause")?; + Ok(None) + } + Self::Toggle => { + playerctl("play-pause")?; + Ok(None) + } + Self::Previous => { + playerctl("previous")?; + Ok(None) + } + Self::Next => { + playerctl("next")?; + Ok(None) + } + } + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized, + { + let result = match input { + "start" => Self::Start, + "stop" => Self::Stop, + "status" => Self::Status, + "play" => Self::Play, + "pause" => Self::Pause, + "toggle" => Self::Toggle, + "previous" => Self::Previous, + "next" => Self::Next, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum Status { + On, + Off, +} + +pub(crate) fn set(status: Status) -> Result<(), Error> { + Ok(cmd::command( + "systemctl", + &[ + "--user", + "--no-block", + match status { + Status::On => "start", + Status::Off => "stop", + }, + "spotify.service", + ], + )?) +} + +fn playerctl(cmd: &str) -> Result<(), Error> { + Ok(cmd::command("playerctl", &["-p", "spotify", cmd])?) +} + +pub(crate) fn pause() -> Result<(), Error> { + playerctl("pause") +} diff --git a/mgr/src/systemd.rs b/mgr/src/systemd.rs new file mode 100644 index 0000000..550e8ff --- /dev/null +++ b/mgr/src/systemd.rs @@ -0,0 +1,38 @@ +use thiserror::Error; + +use super::cmd; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error("unknown status output: \"{output}\"")] + UnknownStatusOutput { output: String }, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum UnitStatus { + Active, + Inactive, +} + +impl UnitStatus { + pub(crate) fn is_active(self) -> bool { + matches!(self, Self::Active) + } +} + +pub(crate) mod user { + use super::{super::cmd, Error, UnitStatus}; + + pub(crate) fn unit_status(unit: &str) -> Result { + let output = cmd::run_command("systemctl", &["--user", "is-active", unit])?; + match output.stdout.as_str().trim() { + "active" => Ok(UnitStatus::Active), + "inactive" => Ok(UnitStatus::Inactive), + other => Err(Error::UnknownStatusOutput { + output: other.to_owned(), + }), + } + } +} diff --git a/mgr/src/theme.rs b/mgr/src/theme.rs new file mode 100644 index 0000000..5c8b55b --- /dev/null +++ b/mgr/src/theme.rs @@ -0,0 +1,101 @@ +use thiserror::Error; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, systemd, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error(transparent)] + Systemd(#[from] systemd::Error), +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + Dark = 0x01, + Light = 0x02, + Status = 0x03, +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::Dark), + 0x02 => Ok(Self::Light), + 0x03 => Ok(Self::Status), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::Dark => vec![0x01], + Self::Light => vec![0x02], + Self::Status => vec![0x03], + } + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized, + { + let result = match input { + "dark" => Self::Dark, + "light" => Self::Light, + "status" => Self::Status, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::Dark => { + cmd::command( + "systemctl", + &["--user", "--no-block", "start", "color-theme-dark.service"], + )?; + Ok(None) + } + Self::Light => { + cmd::command( + "systemctl", + &["--user", "--no-block", "start", "color-theme-light.service"], + )?; + Ok(None) + } + Self::Status => Ok( + if systemd::user::unit_status("color-theme-light.service")?.is_active() { + Some("light".to_owned()) + } else { + Some("dark".to_owned()) + }, + ), + } + } +} diff --git a/mgr/src/weather.rs b/mgr/src/weather.rs new file mode 100644 index 0000000..4465327 --- /dev/null +++ b/mgr/src/weather.rs @@ -0,0 +1,198 @@ +use std::{ + fs, io, + ops::Sub as _, + path::{Path, PathBuf}, +}; + +use thiserror::Error; +use time::format_description::well_known::Iso8601; +use tracing::{Level, event}; + +const CACHE_AGE: time::Duration = time::Duration::hours(1); +const CACHE_DELIMITER: char = '|'; + +use super::{ + Exec, + cli::{self, CliCommand}, + cmd, dirs, systemd, + wire::{WireCommand, server}, +}; + +#[derive(Error, Debug)] +pub enum Error { + #[error(transparent)] + Cmd(#[from] cmd::Error), + #[error(transparent)] + Cli(#[from] cli::ParseError), + #[error(transparent)] + Systemd(#[from] systemd::Error), + #[error(transparent)] + Http(#[from] ureq::Error), + #[error(transparent)] + Dirs(#[from] dirs::Error), + #[error(transparent)] + Io(#[from] io::Error), + #[error("delimiter not found in cache file")] + CacheDelimitedNotFound, + #[error("invalid timestamp \"{input}\" in cache file: {error}")] + CacheTimestampParse { + input: String, + error: time::error::Parse, + }, + #[error("cache timestamp ({cache_timestamp}) is from the future (now: {now})")] + CacheTimestampOverflow { + now: time::UtcDateTime, + cache_timestamp: time::UtcDateTime, + }, + #[error("formatting cache timestamp failed: {error}")] + CacheTimestampFormat { error: time::error::Format }, +} + +#[derive(Debug, Clone, Copy)] +pub enum Action { + Get, +} + +impl WireCommand for Action { + fn parse_wire(mut input: impl Iterator) -> Result { + match input.next().ok_or(server::ParseError::Eof)? { + 0x01 => Ok(Self::Get), + byte => Err(server::ParseError::Unknown(byte)), + } + } + + fn to_wire(&self) -> Vec { + match *self { + Self::Get => vec![0x01], + } + } +} + +impl Exec for Action { + type ExecErr = Error; + + fn execute(&self) -> Result, Self::ExecErr> { + match *self { + Self::Get => Ok(Some(get()?)), + } + } +} + +impl CliCommand for Action { + type ExecErr = Error; + + fn parse_str(input: &str, rest: impl Iterator) -> Result + where + Self: Sized, + { + let result = match input { + "get" => Self::Get, + s => { + return Err(cli::ParseError::UnknownAction { + action: s.to_owned(), + }); + } + }; + + let rest = rest.collect::>(); + if rest.is_empty() { + Ok(result) + } else { + Err(cli::ParseError::UnexpectedInput { rest }) + } + } +} + +#[derive(Debug)] +struct Cache { + timestamp: time::UtcDateTime, + value: String, +} + +fn cache_file() -> Result { + Ok(dirs::xdg_cache_dir()?.join("workstation-mgr.wttr.cache")) +} + +fn store_cache(path: &Path, timestamp: &time::UtcDateTime, value: &str) -> Result<(), Error> { + event!(Level::DEBUG, "storing in cache: {timestamp} {value}"); + Ok(fs::write( + path, + format!( + "{timestamp}{CACHE_DELIMITER}{value}", + timestamp = timestamp + .format(&Iso8601::DEFAULT) + .map_err(|error| Error::CacheTimestampFormat { error })?, + ), + )?) +} + +fn get_cache(path: &Path) -> Result, Error> { + match fs::read_to_string(path) { + Ok(content) => { + let (timestamp, value) = content + .split_once(CACHE_DELIMITER) + .ok_or(Error::CacheDelimitedNotFound)?; + + let cache_timestamp = + time::UtcDateTime::parse(timestamp, &Iso8601::DEFAULT).map_err(|error| { + Error::CacheTimestampParse { + input: timestamp.to_owned(), + error, + } + })?; + + Ok(Some(Cache { + timestamp: cache_timestamp, + value: value.to_owned(), + })) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } +} + +fn request() -> Result { + Ok(ureq::get("https://wttr.in/Ansbach?m&T&format=%c%t") + .call()? + .body_mut() + .read_to_string()?) +} + +fn get_and_update_cache(cache_file: &Path, now: &time::UtcDateTime) -> Result { + event!(Level::DEBUG, "refreshing cache"); + let value = request()?; + store_cache(cache_file, now, &value)?; + Ok(value) +} + +fn get() -> Result { + let cache_file = cache_file()?; + event!(Level::DEBUG, "using cache file {cache_file:?}"); + + let cache = get_cache(&cache_file)?; + event!(Level::DEBUG, "read from cache: {cache:?}"); + + let now = time::UtcDateTime::now(); + + match cache { + Some(cache) => { + let cache_age = now.sub(cache.timestamp); + event!(Level::DEBUG, "cache age: {cache_age}"); + + if cache_age.is_negative() { + return Err(Error::CacheTimestampOverflow { + now, + cache_timestamp: cache.timestamp, + }); + } + + if cache_age <= CACHE_AGE { + event!(Level::DEBUG, "reusing cache"); + Ok(cache.value) + } else { + get_and_update_cache(&cache_file, &now) + } + } + None => get_and_update_cache(&cache_file, &now), + } +} diff --git a/mgr/src/wire/client.rs b/mgr/src/wire/client.rs new file mode 100644 index 0000000..54e564a --- /dev/null +++ b/mgr/src/wire/client.rs @@ -0,0 +1,20 @@ +use std::{ + io::{self, Write as _}, + os::unix::net::UnixStream, +}; + +use thiserror::Error; + +use super::{super::Action, WireCommand as _}; + +#[derive(Debug, Error)] +pub enum SendError { + #[error(transparent)] + Io(#[from] io::Error), +} + +impl Action { + pub fn send(&self, stream: &mut UnixStream) -> Result<(), SendError> { + Ok(stream.write_all(&self.to_wire())?) + } +} diff --git a/mgr/src/wire/mod.rs b/mgr/src/wire/mod.rs new file mode 100644 index 0000000..785bf98 --- /dev/null +++ b/mgr/src/wire/mod.rs @@ -0,0 +1,11 @@ +pub mod client; +pub mod server; +pub mod socket; + +pub(crate) trait WireCommand { + fn parse_wire(input: impl Iterator) -> Result + where + Self: Sized; + + fn to_wire(&self) -> Vec; +} diff --git a/mgr/src/wire/server.rs b/mgr/src/wire/server.rs new file mode 100644 index 0000000..ad37b0b --- /dev/null +++ b/mgr/src/wire/server.rs @@ -0,0 +1,88 @@ +use std::{ + io::{self, Read, Write}, + os::unix::net::{SocketAddr, UnixListener, UnixStream}, + thread, +}; + +use thiserror::Error; +use tracing::{Level, event}; + +use super::{ + super::{Action, Exec as _}, + WireCommand, socket, +}; + +#[derive(Debug, Error)] +pub enum Error { + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Parse(#[from] ParseError), + #[error(transparent)] + Socket(#[from] socket::Error), + #[error(transparent)] + Exec(#[from] crate::ExecError), +} + +#[derive(Debug, Error)] +pub enum ParseError { + #[error("received unexpected eof")] + Eof, + #[error("received unknown byte: {0:#X}")] + Unknown(u8), + #[error("received surplus input: {0:?}")] + Surplus(Vec), +} + +fn handle_client(stream: &mut UnixStream) -> Result<(), Error> { + let input = { + let mut buf = Vec::new(); + stream.read_to_end(&mut buf)?; + buf + }; + + event!(Level::DEBUG, "request data: {input:?}"); + + let action = Action::parse_wire(input.into_iter())?; + + event!(Level::DEBUG, "parsed request: {action:?}"); + + if let Some(output) = action.execute()? { + stream.write_all(output.as_bytes())?; + } + + Ok(()) +} + +pub fn run() -> Result<(), Error> { + event!(Level::DEBUG, "starting server"); + + let socket_path = socket::get_socket_path()?; + + socket::try_remove_socket(&socket_path)?; + + let socket_addr = SocketAddr::from_pathname(socket_path)?; + + event!(Level::DEBUG, "socket address {socket_addr:?}"); + + let listener = UnixListener::bind_addr(&socket_addr)?; + + for stream in listener.incoming() { + let mut stream = stream?; + thread::spawn(move || { + event!(Level::DEBUG, "received request"); + let result = handle_client(&mut stream); + if let Err(e) = result { + let msg = e.to_string(); + event!(Level::ERROR, "action failed: {msg}"); + if let Err(e) = stream.write_all(msg.as_bytes()) { + event!(Level::ERROR, "sending \"{msg}\" failed: {e}"); + } + } + event!(Level::DEBUG, "closing stream"); + drop(stream); + }); + } + + unreachable!() +} diff --git a/mgr/src/wire/socket.rs b/mgr/src/wire/socket.rs new file mode 100644 index 0000000..448ea8a --- /dev/null +++ b/mgr/src/wire/socket.rs @@ -0,0 +1,35 @@ +use std::{ + fs, io, + path::{Path, PathBuf}, +}; + +use thiserror::Error; + +use super::super::dirs; + +#[derive(Debug, Error)] +pub enum Error { + #[error("could not find a suitable socket path")] + NoSocketPathFound, + #[error(transparent)] + Io(#[from] io::Error), + #[error(transparent)] + Dirs(#[from] dirs::Error), +} + +pub fn get_socket_path() -> Result { + if let Some(mut dir) = dirs::xdg_runtime_dir()? { + dir.push("workstation-mgr.sock"); + return Ok(dir); + } + + Err(Error::NoSocketPathFound) +} + +pub(crate) fn try_remove_socket(path: &Path) -> Result<(), Error> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} diff --git a/pkgbuilds/workstation-mgr/.gitignore b/pkgbuilds/workstation-mgr/.gitignore new file mode 100644 index 0000000..e087cfe --- /dev/null +++ b/pkgbuilds/workstation-mgr/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!PKGBUILD diff --git a/pkgbuilds/workstation-mgr/PKGBUILD b/pkgbuilds/workstation-mgr/PKGBUILD new file mode 100644 index 0000000..060c6a8 --- /dev/null +++ b/pkgbuilds/workstation-mgr/PKGBUILD @@ -0,0 +1,41 @@ +# Maintainer: Hannes Körber +pkgname='workstation-mgr' +pkgver=3 +pkgrel=1 +pkgdesc='' +arch=('x86_64') +depends=('glibc' 'gcc-libs') +makedepends=('cargo') +source=() +sha256sums=() + +pkgver() { + cd "/var/lib/dotfiles/mgr/" + git log --oneline . | wc -l +} + +prepare() { + cd "/var/lib/dotfiles/mgr/" + export RUSTUP_TOOLCHAIN=stable + cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" +} + +build() { + cd "/var/lib/dotfiles/mgr/" + export RUSTUP_TOOLCHAIN=stable + export CARGO_TARGET_DIR=/var/lib/makepkg/${pkgname}/build/target + cargo build --frozen --release +} + +check() { + cd "/var/lib/dotfiles/mgr/" + export RUSTUP_TOOLCHAIN=stable + cargo test --frozen +} + +package() { + cd "/var/lib/dotfiles/mgr/" + export CARGO_TARGET_DIR=/var/lib/makepkg/${pkgname}/build/target + install -Dm0755 -t "$pkgdir/usr/bin/" "${CARGO_TARGET_DIR}/release/${pkgname}" + install -Dm0755 -t "$pkgdir/usr/bin/" "${CARGO_TARGET_DIR}/release/workstation-client" +} diff --git a/playbook.yml b/playbook.yml index ab6436c..76f76d7 100644 --- a/playbook.yml +++ b/playbook.yml @@ -210,6 +210,9 @@ - set_fact: aur_packages: + # local packages: + - name: workstation-mgr + - name: portfolio-performance-bin preexec: | #!/usr/bin/env bash @@ -430,9 +433,7 @@ filename="${PKGDEST%/}/${pkgname}-${version}-${arch}${PKGEXT}" - needed_build=0 if [[ ! -e "${filename}" ]] ; then - needed_build=1 makepkg \ --clean \ --nosign || exit 1 diff --git a/projector.sh b/projector.sh deleted file mode 100755 index 59fef76..0000000 --- a/projector.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -switch_back() { - screencfg --setup laptop-only -} -trap switch_back EXIT - -xrandr --output eDP-1 --off --output DP-1 --auto # --mode 1920x1080 - - -printf 'press ENTER or CTRL+C to switch back' -read -r _ diff --git a/update-aur-pkgs.sh b/update-aur-pkgs.sh index c7a8a03..d901811 100755 --- a/update-aur-pkgs.sh +++ b/update-aur-pkgs.sh @@ -1,8 +1,16 @@ #!/usr/bin/env bash for pkg in pkgbuilds/* ; do - printf "checking %s\n" "${pkg}" - git submodule update --remote "${pkg}" + if [[ -n "$(builtin cd "${pkg}" && git rev-parse --show-superproject-working-tree)" ]] ; then + printf "checking git submodule %s\n" "${pkg}" + git submodule update --remote "${pkg}" + else + printf "checking local package %s\n" "${pkg}" + ( + builtin cd "${pkg}" || exit 1 + makepkg --nodeps --nobuild --noextract + ) + fi if git status --porcelain "${pkg}" | grep -q . ; then git add "${pkg}" git commit -m "aur: Update $(basename "${pkg}")"