Compare commits
3 Commits
8b6b4d582d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1d5d88508 | ||
|
|
a542ad60cd | ||
|
|
a51fd2beff |
@@ -207,6 +207,43 @@ func run(ctx context.Context, sd *device.StreamDeck, cfg *config.Config, reg *mo
|
|||||||
cfg.Keys[keyIdx] = keyCfg
|
cfg.Keys[keyIdx] = keyCfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional $(...) sugar → text_command.
|
||||||
|
if keyCfg.TextCommand == "" {
|
||||||
|
if inner, ok := unwrapCmdSubst(keyCfg.Text); ok {
|
||||||
|
keyCfg.TextCommand = inner
|
||||||
|
keyCfg.Text = ""
|
||||||
|
cfg.Keys[keyIdx] = keyCfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic text key: a refresh goroutine owns this key's image. It also
|
||||||
|
// handles an optional poll block (choosing icon_true/icon_false per tick),
|
||||||
|
// so it must take precedence over the plain toggle path below.
|
||||||
|
if keyCfg.TextCommand != "" {
|
||||||
|
baseIcon := keyCfg.Icon
|
||||||
|
if keyCfg.Poll != nil {
|
||||||
|
baseIcon = keyCfg.IconTrue
|
||||||
|
}
|
||||||
|
if strings.ToLower(filepath.Ext(baseIcon)) == ".gif" ||
|
||||||
|
strings.ToLower(filepath.Ext(keyCfg.IconFalse)) == ".gif" {
|
||||||
|
log.Printf("key %d: text_command not supported with GIF icon — skipping refresh", keyIdx)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
trigger := make(chan struct{}, 1)
|
||||||
|
triggers[keyIdx] = trigger
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, kCfg config.KeyConfig, trig chan struct{}) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("panic in refreshTextKey %d: %v", idx, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
refreshTextKey(ctx, sd, idx, kCfg, cfg.IconsDir, trig)
|
||||||
|
}(keyIdx, keyCfg, trigger)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle/status key: managed by a polling goroutine.
|
// Toggle/status key: managed by a polling goroutine.
|
||||||
if keyCfg.Poll != nil {
|
if keyCfg.Poll != nil {
|
||||||
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
if keyCfg.IconTrue == "" || keyCfg.IconFalse == "" {
|
||||||
@@ -488,6 +525,119 @@ func queryPollState(poll *config.PollConfig) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refreshTextKey periodically runs keyCfg.TextCommand and renders its stdout as
|
||||||
|
// the key's text overlay, compositing over the key's base icon each tick (never
|
||||||
|
// accumulating — overlayText always renders onto a fresh image from the base).
|
||||||
|
// If the key also has a Poll block, the base icon is chosen from the polled
|
||||||
|
// state (icon_true/icon_false) so a single goroutine owns the key's image.
|
||||||
|
func refreshTextKey(ctx context.Context, sd *device.StreamDeck, keyIdx int, keyCfg config.KeyConfig, iconsDir string, trigger <-chan struct{}) {
|
||||||
|
// Interval: default 1s, floor 250ms.
|
||||||
|
interval := time.Second
|
||||||
|
if keyCfg.Refresh != "" {
|
||||||
|
if d, err := time.ParseDuration(keyCfg.Refresh); err == nil && d > 0 {
|
||||||
|
interval = d
|
||||||
|
} else if err != nil {
|
||||||
|
log.Printf("key %d: invalid refresh %q, using 1s", keyIdx, keyCfg.Refresh)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if interval < 250*time.Millisecond {
|
||||||
|
interval = 250 * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preload immutable base image(s). An empty name yields a black background.
|
||||||
|
load := func(name string) (image.Image, bool) {
|
||||||
|
if name == "" {
|
||||||
|
return image.NewRGBA(image.Rect(0, 0, sd.ImageWidth(), sd.ImageHeight())), true
|
||||||
|
}
|
||||||
|
img, err := loadImage(filepath.Join(iconsDir, name))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("key %d: load icon %q: %v", keyIdx, name, err)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return img, true
|
||||||
|
}
|
||||||
|
|
||||||
|
pollMode := keyCfg.Poll != nil && keyCfg.IconTrue != "" && keyCfg.IconFalse != ""
|
||||||
|
var baseImg, imgTrue, imgFalse image.Image
|
||||||
|
if pollMode {
|
||||||
|
var ok1, ok2 bool
|
||||||
|
imgTrue, ok1 = load(keyCfg.IconTrue)
|
||||||
|
imgFalse, ok2 = load(keyCfg.IconFalse)
|
||||||
|
if !ok1 || !ok2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var ok bool
|
||||||
|
baseImg, ok = load(keyCfg.Icon) // "" → black background
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render := func() {
|
||||||
|
text := runTextCommand(keyCfg.TextCommand)
|
||||||
|
if text == "" {
|
||||||
|
text = keyCfg.Text // static fallback
|
||||||
|
}
|
||||||
|
base := baseImg
|
||||||
|
if pollMode {
|
||||||
|
if queryPollState(keyCfg.Poll) == 1 {
|
||||||
|
base = imgTrue
|
||||||
|
} else {
|
||||||
|
base = imgFalse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
img := overlayText(base, text, keyCfg.TextColor, sd.ImageWidth())
|
||||||
|
if err := sd.SetKeyImage(keyIdx, img); err != nil {
|
||||||
|
log.Printf("key %d: set dynamic text image: %v", keyIdx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() // set immediately on startup
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
render()
|
||||||
|
case <-trigger:
|
||||||
|
// Wait briefly for a press-triggered command to take effect, then re-render.
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-time.After(300 * time.Millisecond):
|
||||||
|
}
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTextCommand runs cmd via sh -c and returns its stdout with trailing
|
||||||
|
// whitespace/newlines trimmed. Stderr is dropped so it can't pollute the label.
|
||||||
|
func runTextCommand(cmd string) string {
|
||||||
|
out, err := exec.Command("sh", "-c", cmd).Output()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("text_command %q: %v", cmd, err)
|
||||||
|
}
|
||||||
|
return strings.TrimRight(string(out), " \t\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// unwrapCmdSubst returns the inner command if s is exactly "$( ... )".
|
||||||
|
func unwrapCmdSubst(s string) (string, bool) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "$(") && strings.HasSuffix(s, ")") {
|
||||||
|
inner := strings.TrimSpace(s[2 : len(s)-1])
|
||||||
|
if inner != "" {
|
||||||
|
return inner, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
// mustConnect blocks until the device opens successfully.
|
// mustConnect blocks until the device opens successfully.
|
||||||
//
|
//
|
||||||
// Strategy: try quickly at first (device may just be enumerating), then settle
|
// Strategy: try quickly at first (device may just be enumerating), then settle
|
||||||
@@ -707,9 +857,14 @@ func overlayText(img image.Image, text, textColorStr string, keySize int) image.
|
|||||||
|
|
||||||
// Outline offsets for readability on any background.
|
// Outline offsets for readability on any background.
|
||||||
offsets := [8]image.Point{
|
offsets := [8]image.Point{
|
||||||
{-1, -1}, {0, -1}, {1, -1},
|
{-1, -1},
|
||||||
{-1, 0}, {1, 0},
|
{0, -1},
|
||||||
{-1, 1}, {0, 1}, {1, 1},
|
{1, -1},
|
||||||
|
{-1, 0},
|
||||||
|
{1, 0},
|
||||||
|
{-1, 1},
|
||||||
|
{0, 1},
|
||||||
|
{1, 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
@@ -910,11 +1065,11 @@ func defaultConfigPath() string {
|
|||||||
func ensureConfigDir(cfgPath string) error {
|
func ensureConfigDir(cfgPath string) error {
|
||||||
dir := filepath.Dir(cfgPath)
|
dir := filepath.Dir(cfgPath)
|
||||||
iconsDir := filepath.Join(dir, "icons")
|
iconsDir := filepath.Join(dir, "icons")
|
||||||
if err := os.MkdirAll(iconsDir, 0755); err != nil {
|
if err := os.MkdirAll(iconsDir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||||
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0644)
|
return os.WriteFile(cfgPath, []byte(defaultConfig(iconsDir)), 0o644)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -946,4 +1101,3 @@ device:
|
|||||||
keys: {}
|
keys: {}
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,48 @@ keys:
|
|||||||
# command: pactl get-source-mute @DEFAULT_SOURCE@
|
# command: pactl get-source-mute @DEFAULT_SOURCE@
|
||||||
# interval: 2s
|
# interval: 2s
|
||||||
# match: "yes"
|
# match: "yes"
|
||||||
|
|
||||||
|
# --- Dynamic text from command output ---
|
||||||
|
#
|
||||||
|
# text_command: a shell command whose stdout is rendered as the key's label,
|
||||||
|
# re-run every `refresh` interval (default 1s; floored to 250ms). Newlines
|
||||||
|
# in the output become label line breaks; trailing whitespace is trimmed.
|
||||||
|
# `text` (if set) is the static fallback shown before the first run or when
|
||||||
|
# the command errors / outputs nothing. Composited over `icon` each tick.
|
||||||
|
#
|
||||||
|
# NOTE: use the ABSOLUTE binary path — the service runs with a minimal PATH.
|
||||||
|
# Apple Silicon Homebrew installs may live at /opt/homebrew/bin/flow instead.
|
||||||
|
#
|
||||||
|
# Pomodoro (`flow`) live countdown — press toggles start/pause:
|
||||||
|
# 8:
|
||||||
|
# icon: pomodoro.png # base icon; text on top
|
||||||
|
# text_command: "/usr/local/bin/flow status --short" # prints e.g. WORK\n24:12 (real newline)
|
||||||
|
# refresh: 1s
|
||||||
|
# text_color: "#FFFFFF"
|
||||||
|
# command: "/usr/local/bin/flow toggle"
|
||||||
|
#
|
||||||
|
# Separate icon-swap indicator (running vs paused):
|
||||||
|
# 9:
|
||||||
|
# icon_true: running.png
|
||||||
|
# icon_false: paused.png
|
||||||
|
# command: "/usr/local/bin/flow toggle"
|
||||||
|
# poll:
|
||||||
|
# command: "/usr/local/bin/flow status" # prints: state=running phase=work remaining=24:12
|
||||||
|
# interval: 1s
|
||||||
|
# match: "state=running"
|
||||||
|
#
|
||||||
|
# Control keys:
|
||||||
|
# 10:
|
||||||
|
# icon: skip.png
|
||||||
|
# command: "/usr/local/bin/flow skip"
|
||||||
|
# 11:
|
||||||
|
# icon: stop.png
|
||||||
|
# command: "/usr/local/bin/flow stop"
|
||||||
|
# 12:
|
||||||
|
# icon: gui.png
|
||||||
|
# command: "/usr/local/bin/flow gui"
|
||||||
|
#
|
||||||
|
# A text-only live clock (no icon → white text on black), refreshed each second:
|
||||||
|
# 13:
|
||||||
|
# text_command: "date +%H:%M:%S"
|
||||||
|
# refresh: 1s
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ type KeyConfig struct {
|
|||||||
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
|
TextColor string `yaml:"text_color"` // text color: "white" (default), "black", "red", "blue", or hex "#RRGGBB"
|
||||||
Command string `yaml:"command"` // shell command to run on press
|
Command string `yaml:"command"` // shell command to run on press
|
||||||
|
|
||||||
|
// Dynamic text: periodically run TextCommand and render its stdout as the
|
||||||
|
// key's text overlay. Refresh is the interval (default 1s, floor 250ms).
|
||||||
|
// Text (above) is the static fallback / initial value.
|
||||||
|
TextCommand string `yaml:"text_command"` // shell command whose stdout becomes the overlay text
|
||||||
|
Refresh string `yaml:"refresh"` // how often to re-run text_command, e.g. "1s" (default: "1s")
|
||||||
|
|
||||||
// Toggle/status keys: show different icons based on polled state.
|
// Toggle/status keys: show different icons based on polled state.
|
||||||
IconTrue string `yaml:"icon_true"` // icon when poll match is true
|
IconTrue string `yaml:"icon_true"` // icon when poll match is true
|
||||||
IconFalse string `yaml:"icon_false"` // icon when poll match is false
|
IconFalse string `yaml:"icon_false"` // icon when poll match is false
|
||||||
|
|||||||
@@ -57,6 +57,30 @@ modules:
|
|||||||
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
curl -s -X POST https://slack.com/api/dnd.endSnooze \
|
||||||
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
-H "Authorization: Bearer {{env "SLACK_TOKEN"}}"
|
||||||
|
|
||||||
|
# Pomodoro / focus timer — the `flow` CLI.
|
||||||
|
#
|
||||||
|
# Absolute path required (minimal service PATH). Apple Silicon Homebrew installs
|
||||||
|
# live at /opt/homebrew/bin/flow — set that in ~/.config/streamdeck-go/.env:
|
||||||
|
# FLOW_CMD=/opt/homebrew/bin/flow
|
||||||
|
#
|
||||||
|
# Control verbs (start/pause/resume/toggle/skip/stop/reset/gui) run on key press.
|
||||||
|
# `status` prints one line like `state=running phase=work remaining=24:12` for
|
||||||
|
# icon-swap poll blocks (match: "state=running").
|
||||||
|
#
|
||||||
|
# The LIVE countdown label uses a key's `text_command` field directly (not a
|
||||||
|
# module), e.g. text_command: "/usr/local/bin/flow status --short" which prints
|
||||||
|
# a two-line label like WORK\n24:12 (an actual newline between the lines).
|
||||||
|
flow:
|
||||||
|
start: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} start' }
|
||||||
|
pause: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} pause' }
|
||||||
|
resume: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} resume' }
|
||||||
|
toggle: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} toggle' }
|
||||||
|
skip: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} skip' }
|
||||||
|
stop: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} stop' }
|
||||||
|
reset: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} reset' }
|
||||||
|
gui: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} gui' }
|
||||||
|
status: { exec: '{{envDefault "FLOW_CMD" "/usr/local/bin/flow"}} status' }
|
||||||
|
|
||||||
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
# OBS Studio — media player, streaming, and scene/transition control via obs-cmd
|
||||||
#
|
#
|
||||||
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
# Requires: obs-cmd (https://github.com/grigio/obs-cmd)
|
||||||
|
|||||||
@@ -51,20 +51,23 @@ current_addr() {
|
|||||||
'
|
'
|
||||||
;;
|
;;
|
||||||
Darwin)
|
Darwin)
|
||||||
# system_profiler entry per device:
|
# ioreg is the reliable source on macOS — system_profiler SPUSBDataType
|
||||||
# Stream Deck XL:
|
# silently omits the deck on some machines, and its "Location ID: X / N"
|
||||||
# Product ID: 0x00ba
|
# trailing bus number flaps spuriously without an actual replug.
|
||||||
# Vendor ID: 0x0fd9 (Elgato ...)
|
#
|
||||||
# ...
|
# sessionID is unique per USB enumeration session: stable while the
|
||||||
# Location ID: 0x14140000 / 5
|
# device stays plugged in, changes on every replug. Exactly what we want.
|
||||||
# The trailing "/ N" is the bus address — it changes on replug.
|
#
|
||||||
system_profiler SPUSBDataType 2>/dev/null | awk -v pids="$PIDS_RE" '
|
# Elgato vendor in decimal: 4057 (0x0fd9).
|
||||||
/^[[:space:]]*Product ID:/ { pid = $3 }
|
# Stream Deck product IDs in decimal: 186 (0x00ba), 108 (0x006c), 109 (0x006d).
|
||||||
/^[[:space:]]*Vendor ID:/ { vid = $3 }
|
ioreg -p IOUSB -l -w 0 2>/dev/null | awk '
|
||||||
/^[[:space:]]*Location ID:/ {
|
/<class IOUSBHostDevice/ { vid=""; pid=""; sid="" }
|
||||||
sub(/^[[:space:]]*Location ID:[[:space:]]*/, "")
|
/"idVendor"/ { vid=$NF }
|
||||||
if (vid == "0x0fd9" && pid ~ ("^0x(" pids ")$")) {
|
/"idProduct"/ { pid=$NF }
|
||||||
print
|
/"sessionID"/ { sid=$NF }
|
||||||
|
/}/ {
|
||||||
|
if (vid == "4057" && (pid == "186" || pid == "108" || pid == "109") && sid != "") {
|
||||||
|
print sid
|
||||||
exit
|
exit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,14 +109,52 @@ prev=""
|
|||||||
|
|
||||||
curr="$(current_addr)"
|
curr="$(current_addr)"
|
||||||
|
|
||||||
# Always update the state file so the next run sees a fresh baseline.
|
# Only update the state file when the device is present. If we overwrote with
|
||||||
|
# an empty string while the device was absent (e.g. mid-KVM-swap), the very
|
||||||
|
# next run would see prev="" and miss the address change on return.
|
||||||
|
if [[ -n "$curr" ]]; then
|
||||||
printf '%s' "$curr" > "$STATE_FILE"
|
printf '%s' "$curr" > "$STATE_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
# No device present — nothing to do. Don't touch the service.
|
# No device present — nothing to do. Don't touch the service.
|
||||||
if [[ -z "$curr" ]]; then
|
if [[ -z "$curr" ]]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Linux-only: detect a stale hidraw fd held by the daemon. When the device
|
||||||
|
# unplugs, hidraw's open fd survives but its /dev node is removed; procfs
|
||||||
|
# marks the symlink "(deleted)". hid_read_timeout on this fd silently returns
|
||||||
|
# zero bytes, so the daemon's 3-error reconnect path never trips.
|
||||||
|
stale_fd_detected() {
|
||||||
|
[[ "$OS" != "Linux" ]] && return 1
|
||||||
|
local pid
|
||||||
|
pid="$(systemctl --user show -p MainPID --value streamdeck-go.service 2>/dev/null || true)"
|
||||||
|
[[ -z "$pid" || "$pid" == "0" ]] && return 1
|
||||||
|
[[ ! -d "/proc/$pid/fd" ]] && return 1
|
||||||
|
ls -la "/proc/$pid/fd/" 2>/dev/null | grep -qE 'hidraw[0-9]+ \(deleted\)'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Linux-only: detect that the system resumed from suspend after the daemon
|
||||||
|
# started. On resume, the xhci controller may reset the deck's USB device
|
||||||
|
# in place (same bus address, same hidraw node, fd not deleted). The kernel
|
||||||
|
# reset leaves the existing fd's input queue dead — buttons no longer reach
|
||||||
|
# userspace — but no externally visible signal flags the failure. Restarting
|
||||||
|
# the daemon is cheap and reliably fixes it.
|
||||||
|
#
|
||||||
|
# Idempotent by construction: once we restart, the daemon's ActiveEnterTimestamp
|
||||||
|
# moves past the resume event, so this check stops firing until the next sleep.
|
||||||
|
resumed_since_start() {
|
||||||
|
[[ "$OS" != "Linux" ]] && return 1
|
||||||
|
local started
|
||||||
|
started="$(systemctl --user show -p ActiveEnterTimestamp --value streamdeck-go.service 2>/dev/null || true)"
|
||||||
|
[[ -z "$started" || "$started" == "n/a" ]] && return 1
|
||||||
|
local started_epoch
|
||||||
|
started_epoch="$(date -d "$started" +%s 2>/dev/null || true)"
|
||||||
|
[[ -z "$started_epoch" ]] && return 1
|
||||||
|
journalctl -k --since "@$started_epoch" --no-pager 2>/dev/null \
|
||||||
|
| grep -qE 'PM: suspend exit|PM: Finishing wakeup'
|
||||||
|
}
|
||||||
|
|
||||||
reason=""
|
reason=""
|
||||||
|
|
||||||
if [[ -z "$prev" ]]; then
|
if [[ -z "$prev" ]]; then
|
||||||
@@ -127,6 +168,10 @@ elif [[ "$curr" != "$prev" ]]; then
|
|||||||
reason="device address changed: $prev → $curr (likely unplug/replug)"
|
reason="device address changed: $prev → $curr (likely unplug/replug)"
|
||||||
elif ! service_active; then
|
elif ! service_active; then
|
||||||
reason="device present at $curr but service is not active"
|
reason="device present at $curr but service is not active"
|
||||||
|
elif stale_fd_detected; then
|
||||||
|
reason="daemon holds a deleted hidraw fd (post-unplug stale handle)"
|
||||||
|
elif resumed_since_start; then
|
||||||
|
reason="system resumed from suspend since daemon started (USB reset may have invalidated input queue)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "$reason" ]]; then
|
if [[ -n "$reason" ]]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user