Compare commits

...

6 Commits

Author SHA1 Message Date
e224f7ea5b home: add sshm SSH host manager
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 10:29:42 +01:00
72fe33250d home: auto-duck Spotify while speaking on Discord voice
Add a systemd user service (spotify-duck.py) that meters Discord's output
stream and the denoised rnnoise mic via pw-record and lowers Spotify's volume
via wpctl while anyone is speaking, restoring it a beat after silence. Tuning
knobs are exposed as service Environment entries. Ignore Python __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 10:29:25 +01:00
3a53f5bf89 home: route Slack/Obsidian links to the Firefox work profile
Add a firefox-work shim plus an xdg-open shim (http/https only) and an
openLinksInWork wrapper that runs an app with $BROWSER/PATH pointed at them.
Shadow Slack's and Obsidian's .desktop files so their outgoing links open in
the Firefox work profile's existing window. Install obsidian and, for
occasional use, google-chrome; pin http/https/text-html to firefox.desktop so
Chrome can never grab the default-browser role.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 10:29:11 +01:00
12ad9d83d5 home: rebind Super+W to Firefox, Super+Shift+W to Firefox (Work)
The end-4 dots bind Super+W to $browser, which launch_first_available.sh
resolves to Chrome when it is installed. Append an unbind + rebind to
hypr/custom/keybinds.lua (sourced after the default) on each activation so
Super+W opens Firefox and Super+Shift+W opens the Firefox work profile,
re-added after every dots recopy. Chain reloadHyprland after it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 10:28:07 +01:00
89d463e2cb gaming: udev rules for phantom controllers and WebHID configurators
Clear ID_INPUT_JOYSTICK on the Keychron keyboard (3434) and X3PRO mouse
receiver (3151) so Steam/games stop grabbing them as a phantom controller.
Also grant the active session uaccess to those devices' hidraw nodes via a
60-*.rules package file (must sort before systemd's 73-seat-late.rules) so
their browser-based WebHID configurators work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 10:27:31 +01:00
fc2c8b9cf8 gaming: install Minecraft Bedrock via nix-flatpak
Add the nix-flatpak flake input and wire its NixOS module into the gaming
module to declaratively install io.mrarm.mcpelauncher from Flathub. The
Flatpak build tracks the newer, pairip-protected Bedrock releases ahead of
the nixpkgs mcpelauncher package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 10:27:10 +01:00
7 changed files with 472 additions and 3 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
__pycache__/
*.pyc

16
flake.lock generated
View File

@@ -137,6 +137,21 @@
"type": "github" "type": "github"
} }
}, },
"nix-flatpak": {
"locked": {
"lastModified": 1783368811,
"narHash": "sha256-0H8jDwR4Kegb3heaTrH1ftbgKfZVDT8JE+46uXxDy/Q=",
"owner": "gmodena",
"repo": "nix-flatpak",
"rev": "20d42f0ee98c9fe9f85e8d1de474f1409ed10d05",
"type": "github"
},
"original": {
"owner": "gmodena",
"repo": "nix-flatpak",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1784796856, "lastModified": 1784796856,
@@ -203,6 +218,7 @@
"home-manager": "home-manager", "home-manager": "home-manager",
"hyprland-preview-share-picker": "hyprland-preview-share-picker", "hyprland-preview-share-picker": "hyprland-preview-share-picker",
"illogical-flake": "illogical-flake", "illogical-flake": "illogical-flake",
"nix-flatpak": "nix-flatpak",
"nixpkgs": "nixpkgs", "nixpkgs": "nixpkgs",
"spicetify-nix": "spicetify-nix" "spicetify-nix": "spicetify-nix"
} }

View File

@@ -48,6 +48,11 @@
url = "github:Gerg-L/spicetify-nix"; url = "github:Gerg-L/spicetify-nix";
inputs.nixpkgs.follows = "nixpkgs"; inputs.nixpkgs.follows = "nixpkgs";
}; };
# Declarative Flatpak app/remote management (extends services.flatpak). Used
# for the Minecraft Bedrock launcher, whose Flatpak build tracks newer,
# pairip-protected Bedrock releases ahead of the nixpkgs package.
nix-flatpak.url = "github:gmodena/nix-flatpak";
}; };
outputs = { nixpkgs, home-manager, ... }@inputs: { outputs = { nixpkgs, home-manager, ... }@inputs: {

244
home/spotify-duck.py Normal file
View File

@@ -0,0 +1,244 @@
#!/usr/bin/env python3
"""Voice-activity ducking: lower Spotify while you or others speak on Discord.
Design (see home/spotify-ducking.nix for the wiring):
* "Someone else is speaking" is measured by tapping the MONITOR of Discord's
(Vesktop's) own playback stream via `pw-record --target <serial>`. That
captures ONLY Vesktop's output, so Spotify's own audio can never leak into
the meter and cause a feedback duck.
* "You are speaking" is measured by tapping your denoised mic (rnnoise_source)
the same way -- but only counts while you're actually in a voice call, which
we detect by Vesktop holding an open capture (Stream/Input/Audio) stream.
So talking near your mic outside a call won't touch the music.
* When either crosses its threshold we ride ONLY Spotify's own stream volume
down to DUCK_LEVEL and back up after RELEASE_MS of silence. Nothing else on
the system is affected, and we never reroute audio, so switching output
devices (earbuds, headset, HDMI) needs no special handling.
Everything is discovered dynamically from `pw-dump`, so it survives Discord and
Spotify restarts, leaving/rejoining calls, and node-id churn. All tuning is via
environment variables (set in the systemd unit).
"""
import json
import math
import os
import signal
import struct
import subprocess
import threading
import time
# ---- tunables (overridable from the environment) --------------------------
RATE = 16000 # meter sample rate
FRAME_BYTES = int(RATE * 0.05) * 2 # 50 ms of s16 mono
DUCK_LEVEL = float(os.environ.get("DUCK_LEVEL", "0.2")) # ducked = base * this
MIC_TH = float(os.environ.get("MIC_THRESHOLD", "0.02")) # you-speaking RMS gate
DISC_TH = float(os.environ.get("DISC_THRESHOLD", "0.012")) # others-speaking gate
RELEASE = float(os.environ.get("RELEASE_MS", "700")) / 1000.0 # silence hold
MIC_TARGET = os.environ.get("MIC_TARGET", "rnnoise_source") # mic node.name
DISCORD_APP = os.environ.get("DISCORD_APP", "vesktop") # application.name
SPOTIFY_MATCH = os.environ.get("SPOTIFY_MATCH", "spotify").lower()
POLL = float(os.environ.get("POLL_SEC", "1.0")) # graph-discovery period
PW_RECORD = "pw-record"
PW_DUMP = "pw-dump"
WPCTL = "wpctl"
def rms(buf):
n = len(buf) // 2
if n == 0:
return 0.0
s = struct.unpack("<%dh" % n, buf[: n * 2])
return math.sqrt(sum(x * x for x in s) / n) / 32768.0
class Meter(threading.Thread):
"""Continuously reports the RMS level of one PipeWire node's monitor.
`.target` is a node name or object.serial to capture, or None to pause.
Restarts its `pw-record` automatically when the target changes or the
captured stream goes away (e.g. Discord closes it on call end).
"""
def __init__(self):
super().__init__(daemon=True)
self.level = 0.0
self._target = None
self._cur = None
self._proc = None
self._lock = threading.Lock()
def set_target(self, t):
with self._lock:
self._target = t
def _start(self, tgt):
self._proc = subprocess.Popen(
[PW_RECORD, "--target", str(tgt), "--rate", str(RATE),
"--channels", "1", "--format", "s16", "--latency", "50ms", "-"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
self._cur = tgt
def _stop(self):
if self._proc:
self._proc.terminate()
try:
self._proc.wait(1)
except subprocess.TimeoutExpired:
self._proc.kill()
self._proc, self._cur = None, None
def run(self):
while True:
with self._lock:
tgt = self._target
if tgt is None:
self._stop()
self.level = 0.0
time.sleep(0.1)
continue
if tgt != self._cur:
self._stop()
self._start(tgt)
buf = b""
while len(buf) < FRAME_BYTES:
chunk = self._proc.stdout.read(FRAME_BYTES - len(buf))
if not chunk:
break
buf += chunk
if not buf: # stream ended -> respawn next loop
self._stop()
self.level = 0.0
time.sleep(0.1)
continue
self.level = rms(buf)
# shared graph state, refreshed by the discovery thread
G = {"discord_serial": None, "in_call": False, "spotify_id": None}
def discover():
while True:
try:
dump = json.loads(subprocess.check_output([PW_DUMP]))
except Exception:
time.sleep(POLL)
continue
dser = incall = spid = None
incall = False
for o in dump:
if o.get("type") != "PipeWire:Interface:Node":
continue
p = (o.get("info") or {}).get("props") or {}
mc = p.get("media.class", "")
app = (p.get("application.name") or "")
binn = (p.get("application.process.binary") or "").lower()
nn = (p.get("node.name") or "").lower()
if mc == "Stream/Output/Audio" and app == DISCORD_APP:
dser = p.get("object.serial")
elif mc == "Stream/Input/Audio" and app == DISCORD_APP:
incall = True
elif mc == "Stream/Output/Audio" and (
SPOTIFY_MATCH in app.lower() or SPOTIFY_MATCH in binn
or SPOTIFY_MATCH in nn
):
spid = o["id"]
G.update(discord_serial=dser, in_call=incall, spotify_id=spid)
time.sleep(POLL)
def get_vol(nid):
try:
out = subprocess.check_output(
[WPCTL, "get-volume", str(nid)], stderr=subprocess.DEVNULL
).decode()
return float(out.split()[1]) # "Volume: 0.42 [MUTED]" -> 0.42
except Exception:
return None
def set_vol(nid, v):
v = max(0.0, min(1.5, v))
subprocess.run([WPCTL, "set-volume", str(nid), "%.3f" % v],
stderr=subprocess.DEVNULL, check=False)
# ducking state, shared so the SIGTERM handler can un-duck on shutdown.
# `base` is your genuine chosen volume; it is ONLY ever sampled while un-ducked
# and settled (see the control loop), so our own ducked writes can never feed
# back into it and ratchet the volume toward zero.
S = {"ducked": False, "base": 1.0, "spid": None}
_slock = threading.Lock()
SETTLE = 0.5 # seconds to let a wpctl write propagate before trusting a read
def restore_and_exit(*_):
with _slock:
if S["ducked"] and S["spid"] is not None:
set_vol(S["spid"], S["base"])
os._exit(0)
def main():
signal.signal(signal.SIGTERM, restore_and_exit)
signal.signal(signal.SIGINT, restore_and_exit)
mic, disc = Meter(), Meter()
mic.start()
disc.start()
threading.Thread(target=discover, daemon=True).start()
last_voice = 0.0
known_spid = None # Spotify node we've already learned the base volume of
base_deadline = 0.0 # don't sample base again until monotonic() past this
while True:
in_call = G["in_call"]
spid = G["spotify_id"]
# only meter while in a call -> zero idle CPU otherwise
mic.set_target(MIC_TARGET if in_call else None)
disc.set_target(G["discord_serial"] if in_call else None)
now = time.monotonic()
if in_call and (mic.level > MIC_TH or disc.level > DISC_TH):
last_voice = now
want_duck = in_call and (now - last_voice) < RELEASE
with _slock:
S["spid"] = spid
if spid is None:
S["ducked"] = False # nothing to control
known_spid = None
else:
if spid != known_spid: # new Spotify stream: learn its volume
known_spid = spid
v = get_vol(spid)
if v is not None:
S["base"] = v
S["ducked"] = False
base_deadline = now + SETTLE
if want_duck and not S["ducked"]:
set_vol(spid, S["base"] * DUCK_LEVEL)
S["ducked"] = True
base_deadline = now + SETTLE
elif not want_duck and S["ducked"]:
set_vol(spid, S["base"])
S["ducked"] = False
base_deadline = now + SETTLE
elif not S["ducked"] and now >= base_deadline:
# un-ducked and our last write has settled: this reading is
# your real volume, so adopt it (picks up manual changes).
v = get_vol(spid)
if v is not None:
S["base"] = v
base_deadline = now + SETTLE
time.sleep(0.03)
if __name__ == "__main__":
main()

53
home/spotify-ducking.nix Normal file
View File

@@ -0,0 +1,53 @@
{ pkgs, ... }:
# Voice-activity ducking: while you OR someone else is speaking in a Discord
# (Vesktop) voice call, Spotify's volume is lowered; it returns to normal a
# beat after everyone goes quiet. Nothing else on the system is touched.
#
# The daemon (./spotify-duck.py) meters Discord's own output stream and your
# denoised mic (rnnoise_source, from modules/noise-suppression.nix) directly via
# `pw-record`, so it never reroutes audio and is independent of which output
# device is active. See that file's header for the full rationale. Tune the
# behaviour with the Environment entries below and `systemctl --user restart
# spotify-duck` (no rebuild needed to experiment; make it permanent here after).
let
spotify-duck = pkgs.writeShellApplication {
name = "spotify-duck";
# pw-record/pw-dump live in pipewire; wpctl in wireplumber; python3 to run it.
runtimeInputs = [ pkgs.python3 pkgs.pipewire pkgs.wireplumber ];
text = ''exec python3 ${./spotify-duck.py} "$@"'';
};
in
{
systemd.user.services.spotify-duck = {
Unit = {
Description = "Duck Spotify while speaking on Discord (voice-activity)";
After = [ "pipewire.service" "wireplumber.service" ];
};
Service = {
ExecStart = "${spotify-duck}/bin/spotify-duck";
Restart = "on-failure";
RestartSec = 3;
# Tuning knobs — override and `systemctl --user restart spotify-duck`.
Environment = [
"DUCK_LEVEL=0.35" # ducked volume = your current volume * this (35%)
"MIC_THRESHOLD=0.004" # you-speaking RMS gate (measured: speech 0.004-0.015,
# silence <0.0025 on rnnoise_source). Lower toward
# 0.003 if soft speech is missed; raise if it dips
# randomly.
"DISC_THRESHOLD=0.008" # others-speaking RMS gate on Discord's output
"RELEASE_MS=900" # restore this long after the last speech
"MIC_TARGET=rnnoise_source" # mic node; the denoised source you use in Discord
"DISCORD_APP=vesktop" # application.name of the Discord client
"SPOTIFY_MATCH=spotify" # substring identifying Spotify's stream
];
};
# default.target (not graphical-session.target) so it reliably starts on
# login regardless of how the Hyprland session activates targets; the daemon
# tolerates PipeWire not being up yet and self-heals.
Install.WantedBy = [ "default.target" ];
};
}

View File

@@ -1,4 +1,4 @@
{ inputs, lib, pkgs, ... }: { config, inputs, lib, pkgs, ... }:
let let
share-picker = inputs.hyprland-preview-share-picker.packages.${pkgs.stdenv.hostPlatform.system}.default; share-picker = inputs.hyprland-preview-share-picker.packages.${pkgs.stdenv.hostPlatform.system}.default;
@@ -27,11 +27,51 @@ let
"${pkgs.discord}/opt/Discord/modules/discord_desktop_core/app/images/discord.svg" \ "${pkgs.discord}/opt/Discord/modules/discord_desktop_core/app/images/discord.svg" \
"$out/share/icons/hicolor/scalable/apps/discord.svg" "$out/share/icons/hicolor/scalable/apps/discord.svg"
''; '';
firefoxBin = "${config.programs.firefox.finalPackage}/bin/firefox";
# Redirects an app's outgoing links into the Firefox "work" profile, reusing
# the same --name/WM class as the firefox-work desktop entry so links land in
# the existing Work window. Linux has no per-app "use browser Y" mapping, so
# instead we launch the app (below) with this bin dir first on PATH and
# $BROWSER pointed here — covering both ways an app opens a link:
# * `firefox-work` — the value we set for $BROWSER
# * `xdg-open` — a shim that sends only http/https to the work profile
# and delegates every other URI to the real xdg-open
# (Apps that call the org.freedesktop.portal.OpenURI portal directly bypass
# both and still use the global default — that can't be overridden per-app.)
work-browser = pkgs.symlinkJoin {
name = "firefox-work-browser-shim";
paths = [
(pkgs.writeShellScriptBin "firefox-work" ''
exec ${firefoxBin} -P work --name firefox-work "$@"
'')
(pkgs.writeShellScriptBin "xdg-open" ''
case "$1" in
http://*|https://*)
exec ${firefoxBin} -P work --name firefox-work "$@" ;;
*)
exec ${pkgs.xdg-utils}/bin/xdg-open "$@" ;;
esac
'')
];
};
# Wraps an app binary so its links open in the Firefox work profile.
openLinksInWork = name: exe: pkgs.writeShellScriptBin name ''
export PATH=${work-browser}/bin:$PATH
export BROWSER=firefox-work
exec ${exe} "$@"
'';
slack-work-links = openLinksInWork "slack-work-links" "${pkgs.slack}/bin/slack";
obsidian-work-links = openLinksInWork "obsidian-work-links" "${pkgs.obsidian}/bin/obsidian";
in in
{ {
imports = [ imports = [
inputs.illogical-flake.homeManagerModules.default inputs.illogical-flake.homeManagerModules.default
inputs.spicetify-nix.homeManagerModules.spicetify inputs.spicetify-nix.homeManagerModules.spicetify
./spotify-ducking.nix
]; ];
home.username = "zephrynis"; home.username = "zephrynis";
@@ -142,10 +182,32 @@ in
# sets this to qt6ct) # sets this to qt6ct)
home.sessionVariables.QT_QPA_PLATFORMTHEME = lib.mkForce "kde"; home.sessionVariables.QT_QPA_PLATFORMTHEME = lib.mkForce "kde";
# Super+W opens Firefox instead of Chrome. The dots' hyprland/keybinds.lua
# binds it to $browser, which launch_first_available.sh resolves to the first
# installed of google-chrome-stable, zen, firefox, ... — so with Chrome
# installed it lands on Chrome. custom/keybinds.lua is sourced after the
# default, so unbind + rebind wins; re-appended after every recopy.
# Super+Shift+W is the "variant" pairing (Shift = variant throughout the dots)
# for the Firefox work profile, matching the firefox-work shim/desktop entry.
home.activation.browserKeybindFirefox = lib.hm.dag.entryAfter [ "hyprlandMonitorLayout" ] ''
hyprCustomKeybinds="$HOME/.config/hypr/custom/keybinds.lua"
if [ -f "$hyprCustomKeybinds" ] && ! grep -q 'App: Firefox' "$hyprCustomKeybinds"; then
cat >> "$hyprCustomKeybinds" << 'EOF'
-- Super+W -> Firefox (appended by nix-flake, overrides the dots' browser bind)
hl.unbind("SUPER + W")
hl.bind("SUPER + W", hl.dsp.exec_cmd("firefox"), { description = "App: Firefox" })
-- Super+Shift+W -> Firefox work profile (matches the firefox-work shim/WM class)
hl.bind("SUPER + SHIFT + W", hl.dsp.exec_cmd("firefox -P work --name firefox-work"), { description = "App: Firefox (Work)" })
EOF
echo "Rebound Super+W to Firefox and Super+Shift+W to Firefox (Work) in hypr/custom/keybinds.lua"
fi
'';
# The illogical-flake copy step deletes/recreates ~/.config/hypr while # The illogical-flake copy step deletes/recreates ~/.config/hypr while
# Hyprland is running; its mid-copy reload fails ("cannot open hyprland.lua") # Hyprland is running; its mid-copy reload fails ("cannot open hyprland.lua")
# and the error banner sticks. Reload once the configs are back in place. # and the error banner sticks. Reload once the configs are back in place.
home.activation.reloadHyprland = lib.hm.dag.entryAfter [ "hyprlandMonitorLayout" ] '' home.activation.reloadHyprland = lib.hm.dag.entryAfter [ "browserKeybindFirefox" ] ''
for instance in /run/user/$(id -u)/hypr/*/; do for instance in /run/user/$(id -u)/hypr/*/; do
[ -d "$instance" ] || continue [ -d "$instance" ] || continue
HYPRLAND_INSTANCE_SIGNATURE="$(basename "$instance")" \ HYPRLAND_INSTANCE_SIGNATURE="$(basename "$instance")" \
@@ -453,11 +515,47 @@ EOF
}; };
}; };
# Route Slack's and Obsidian's outgoing links into the Firefox work profile
# by shadowing their package .desktop files (user data dir wins in
# XDG_DATA_DIRS) with copies whose Exec points at the openLinksInWork wrapper.
# Every other field is kept identical to the upstream entry.
xdg.desktopEntries.slack = {
name = "Slack";
genericName = "Slack Client for Linux";
comment = "Slack Desktop";
exec = "${slack-work-links}/bin/slack-work-links -s %U";
icon = "slack";
type = "Application";
startupNotify = true;
categories = [ "GNOME" "GTK" "Network" "InstantMessaging" ];
mimeType = [ "x-scheme-handler/slack" ];
settings.StartupWMClass = "Slack";
};
xdg.desktopEntries.obsidian = {
name = "Obsidian";
comment = "Knowledge base";
exec = "${obsidian-work-links}/bin/obsidian-work-links %u";
icon = "obsidian";
type = "Application";
categories = [ "Office" ];
mimeType = [ "x-scheme-handler/obsidian" ];
};
xdg.mimeApps = { xdg.mimeApps = {
enable = true; enable = true;
defaultApplications = { defaultApplications = {
"inode/directory" = "org.gnome.Nautilus.desktop"; "inode/directory" = "org.gnome.Nautilus.desktop";
"x-scheme-handler/claude-cli" = "claude-code-url-handler.desktop"; "x-scheme-handler/claude-cli" = "claude-code-url-handler.desktop";
# Keep Firefox (personal profile) as the browser for all links. Pinned
# explicitly so google-chrome — installed below as an occasional-use app —
# can never register itself as the default link handler (HM owns
# mimeapps.list as a read-only symlink, so Chrome's first-run "make
# default" prompt physically can't rewrite these).
"x-scheme-handler/http" = "firefox.desktop";
"x-scheme-handler/https" = "firefox.desktop";
"x-scheme-handler/about" = "firefox.desktop";
"x-scheme-handler/unknown" = "firefox.desktop";
"text/html" = "firefox.desktop";
}; };
}; };
@@ -465,6 +563,8 @@ EOF
# As a plain package, not programs.gh — the HM module symlinks a read-only # As a plain package, not programs.gh — the HM module symlinks a read-only
# config.yml into the store, which breaks `gh auth login`'s first-run write. # config.yml into the store, which breaks `gh auth login`'s first-run write.
gh gh
# sshm: TUI to manage and connect to SSH hosts (reads/writes ~/.ssh/config)
sshm
nautilus nautilus
# Vesktop: Discord+Vencord with a real Wayland/PipeWire screenshare — a # Vesktop: Discord+Vencord with a real Wayland/PipeWire screenshare — a
# sane WebRTC bitrate (the official `discord` client starved it into # sane WebRTC bitrate (the official `discord` client starved it into
@@ -475,6 +575,12 @@ EOF
vesktop vesktop
discord-icon discord-icon
slack slack
# Obsidian: Markdown knowledge base / note-taking (unfree; allowUnfree
# already enabled for the other proprietary apps above).
obsidian
# Chrome: occasional-use only. Firefox stays the default link handler —
# the xdg.mimeApps http/https pins above keep Chrome from grabbing links.
google-chrome
claude-code claude-code
ripgrep ripgrep
fd fd

View File

@@ -1,6 +1,8 @@
{ pkgs, ... }: { inputs, pkgs, ... }:
{ {
imports = [ inputs.nix-flatpak.nixosModules.nix-flatpak ];
programs.steam = { programs.steam = {
enable = true; enable = true;
remotePlay.openFirewall = true; remotePlay.openFirewall = true;
@@ -17,4 +19,45 @@
prismlauncher prismlauncher
protonup-qt protonup-qt
]; ];
# Minecraft Bedrock via mcpelauncher (unofficial; runs the Android ARM build
# through a libc shim — you must own Bedrock on Google Play). Installed as a
# Flatpak (io.mrarm.mcpelauncher) rather than the nixpkgs package, whose
# launcher lagged the newer, pairip-protected Bedrock releases. nix-flatpak
# adds the Flathub remote (its default) and installs the app on activation.
services.flatpak = {
enable = true;
packages = [ "io.mrarm.mcpelauncher" ];
};
# Some devices expose a control interface that udev misdetects as a joystick,
# so Steam/games pick up a phantom controller and grab input. Clear
# ID_INPUT_JOYSTICK for them (extraRules → 99-local.rules is fine here, since
# this only rewrites a udev property later consumers read):
# 3434 = Keychron keyboard
# 3151 = "2.4G Wireless Mouse" (X3PRO) receiver
services.udev.extraRules = ''
SUBSYSTEM=="input", ATTRS{idVendor}=="3434", ENV{ID_INPUT_JOYSTICK}=""
SUBSYSTEM=="input", ATTRS{idVendor}=="3151", ENV{ID_INPUT_JOYSTICK}=""
'';
# Browser-based (WebHID) configurators need the logged-in user to have access
# to the device's raw HID nodes:
# 3434 = Keychron keyboard (launcher.keychron.com)
# 3151 = X3PRO mouse (its web editor)
# TAG+="uaccess" makes systemd-logind grant the active session an ACL — but
# that tag is CONSUMED by systemd's 73-seat-late.rules, so the rule must sort
# BEFORE it. services.udev.extraRules lands in 99-local.rules (too late), so
# ship this as a package-provided 60-*.rules file instead.
# (WebHID is Chromium-only — use Chrome, not Firefox.)
services.udev.packages = [
(pkgs.writeTextFile {
name = "hid-webconfig-uaccess";
destination = "/etc/udev/rules.d/60-hid-webconfig-uaccess.rules";
text = ''
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3434", TAG+="uaccess"
KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="3151", TAG+="uaccess"
'';
})
];
} }