mirror of
https://github.com/zephrynis/nix-flake.git
synced 2026-08-18 05:55:53 +00:00
Compare commits
6 Commits
eed3db9f05
...
e224f7ea5b
| Author | SHA1 | Date | |
|---|---|---|---|
| e224f7ea5b | |||
| 72fe33250d | |||
| 3a53f5bf89 | |||
| 12ad9d83d5 | |||
| 89d463e2cb | |||
| fc2c8b9cf8 |
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
16
flake.lock
generated
16
flake.lock
generated
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
244
home/spotify-duck.py
Normal 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
53
home/spotify-ducking.nix
Normal 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" ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
'';
|
||||||
|
})
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user