Compare commits

...

16 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
eed3db9f05 home: relabel Vesktop as Discord and fix screenshare quality
Add vesktop (Discord+Vencord) with a proper Wayland/PipeWire screenshare
to replace the official discord client, whose Linux WebRTC path starved
the stream into macroblocks. A launcher-entry override + an icon-only
derivation (real Discord SVG, no runtime closure) relabel it to
"Discord" with the genuine logo. Drops the official discord package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:54:28 +01:00
fde84f60ff home: illogical-impulse overrides, Firefox, Spicetify, share picker
The bulk of the home-manager config: re-applied Hyprland dots overrides
(UK layout, monitor layout, Qt=kde theme, foot/kitty Ctrl+C, cursor,
fish greeting), the themed hyprland-preview-share-picker + xdph wiring,
Firefox personal/work profiles, Spicetify, mimeapps, and the CLI/app
package set. Also wires up the Trayscale autostart and 1Password Firefox
overlay on the home side.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:54:15 +01:00
05a6d6cc69 hosts: set UK keymap and locale for TTY and greeter
console.keyMap = uk and xkb.layout = gb so the TTYs and SDDM greeter
match the UK layout (Hyprland's own layout is set in home).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:58 +01:00
ff8196fc77 hosts: enable 1Password (GUI + CLI) with Firefox integration
programs._1password-gui/_1password with polkit owner zephrynis, and
allowlist NixOS's .firefox-wrapped binary so the browser extension can
connect to the desktop app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:58 +01:00
91053f9693 noise-suppression: RNNoise virtual mic via PipeWire, enable on host
App-independent mic denoising as a PipeWire filter-chain exposing a
"Noise Canceling source" — replaces Discord's unusable Krisp and works
in every app. Imported into the nixos-pc host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:47 +01:00
4b3983d895 tailscale: add service + Trayscale, enable on host
Tailscale with --operator=zephrynis so the daemon is drivable without
sudo (Trayscale + tailscale switch/login from a plain shell), plus the
Trayscale systray app. Imported into the nixos-pc host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:47 +01:00
3d8fec08e5 gaming: add Prism Launcher
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:35 +01:00
3a2145c076 desktop: autologin into Hyprland, Qt/KDE theming, dots fonts
Disable UWSM (black-screens under NVIDIA), autologin to the Hyprland
session, restore upstream qt platformTheme=kde + Darkly, and install the
fonts the end-4 dots actually reference (Google Sans Flex, Readex Pro,
Space Grotesk).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:35 +01:00
b57b5fea7c core: enable redistributable firmware for Realtek dongle
Pulls in the RTL8821CU WiFi/Bluetooth blobs (and other devices).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:35 +01:00
b90e17b6a6 flake: add share-picker, firefox-addons, google-sans-flex, spicetify inputs
Pin the new inputs the config now depends on (GTK4 screenshare picker,
rycee's firefox-addons tree, the Google Sans Flex font, and spicetify-nix)
and add flake.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 00:51:35 +01:00
12 changed files with 1343 additions and 5 deletions

2
.gitignore vendored Normal file
View File

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

265
flake.lock generated Normal file
View File

@@ -0,0 +1,265 @@
{
"nodes": {
"dotfiles": {
"flake": false,
"locked": {
"lastModified": 1778966846,
"narHash": "sha256-N7HUWD3dliyPDXA5qXhuma5segnnopgPo2nv5iFaeXc=",
"ref": "refs/heads/main",
"rev": "c0706258b1ff22c70f6c0e51c4b812e63703f620",
"revCount": 6244,
"submodules": true,
"type": "git",
"url": "https://github.com/end-4/dots-hyprland"
},
"original": {
"submodules": true,
"type": "git",
"url": "https://github.com/end-4/dots-hyprland"
}
},
"firefox-addons": {
"flake": false,
"locked": {
"lastModified": 1784402804,
"narHash": "sha256-NKjHErNuyO2x+YBUNOJy/8uKXfuMX60SLFuvZiMYdA8=",
"owner": "rycee",
"repo": "nur-expressions",
"rev": "775bd8e526823ab58efe5d1fc269f2a60c3de2c1",
"type": "gitlab"
},
"original": {
"owner": "rycee",
"repo": "nur-expressions",
"type": "gitlab"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"illogical-flake",
"nur",
"nixpkgs"
]
},
"locked": {
"lastModified": 1733312601,
"narHash": "sha256-4pDvzqnegAfRkPwO3wmwBhVi/Sye1mzps0zHWYnP88c=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "205b12d8b7cd4802fbcb8e8ef6a0f1408781a4f9",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"google-sans-flex": {
"flake": false,
"locked": {
"lastModified": 1763459024,
"narHash": "sha256-HMAS0L/Tsqyl1xI16cyIzg9LEb6Dyq91JY4wqFQV9Vs=",
"owner": "end-4",
"repo": "google-sans-flex",
"rev": "251aa5abd30496368f634e54ce2a508fe5a2fdfa",
"type": "github"
},
"original": {
"owner": "end-4",
"repo": "google-sans-flex",
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784407317,
"narHash": "sha256-iZrxHToDJWnvt+5LGAtvuQMTy1NZYlNEKbKaVtBJNcc=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "39411a8e12a5526d992e65bc7e3dc9a4414d6713",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"hyprland-preview-share-picker": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1782052593,
"narHash": "sha256-XE6RD/4Mhw/ZRBj0v94kLOERElat5V+e/X0L9eUGf7M=",
"ref": "refs/heads/master",
"rev": "e2f30ff85486e557018523da45ccbc846e3a499c",
"revCount": 55,
"submodules": true,
"type": "git",
"url": "https://github.com/WhySoBad/hyprland-preview-share-picker"
},
"original": {
"submodules": true,
"type": "git",
"url": "https://github.com/WhySoBad/hyprland-preview-share-picker"
}
},
"illogical-flake": {
"inputs": {
"dotfiles": "dotfiles",
"nixpkgs": [
"nixpkgs"
],
"nur": "nur",
"quickshell": "quickshell"
},
"locked": {
"lastModified": 1779068758,
"narHash": "sha256-7osZ24TmUVDWZDxQ8Bi/P6GYiqTWw5azNWaVd+GfohY=",
"owner": "soymou",
"repo": "illogical-flake",
"rev": "8629ef302b956cf6ab0089338a867dbe844156fd",
"type": "github"
},
"original": {
"owner": "soymou",
"repo": "illogical-flake",
"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": {
"locked": {
"lastModified": 1784796856,
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nur": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"illogical-flake",
"nixpkgs"
]
},
"locked": {
"lastModified": 1779066378,
"narHash": "sha256-GqKhLVZTmZa68mLlCat57m54aeYPhrNAPVTLd2pGKYk=",
"owner": "nix-community",
"repo": "NUR",
"rev": "ab477ab3a75a85a68a80d9d26a572f9de9742808",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "NUR",
"type": "github"
}
},
"quickshell": {
"inputs": {
"nixpkgs": [
"illogical-flake",
"nixpkgs"
]
},
"locked": {
"lastModified": 1778488696,
"narHash": "sha256-QSWgYuZUCNUJ/cxmaq83WkcT7lHQDDfsPVgH+96kIl0=",
"ref": "refs/heads/master",
"rev": "7d1c9a9c6721606b129829134d6f614f015621e2",
"revCount": 818,
"type": "git",
"url": "https://git.outfoxxed.me/outfoxxed/quickshell"
},
"original": {
"type": "git",
"url": "https://git.outfoxxed.me/outfoxxed/quickshell"
}
},
"root": {
"inputs": {
"firefox-addons": "firefox-addons",
"google-sans-flex": "google-sans-flex",
"home-manager": "home-manager",
"hyprland-preview-share-picker": "hyprland-preview-share-picker",
"illogical-flake": "illogical-flake",
"nix-flatpak": "nix-flatpak",
"nixpkgs": "nixpkgs",
"spicetify-nix": "spicetify-nix"
}
},
"spicetify-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1785048222,
"narHash": "sha256-LjFVfwxensz76SjnvofF1Jsw+xTLd8Qp31W0J6nh2XI=",
"owner": "Gerg-L",
"repo": "spicetify-nix",
"rev": "17e524f330c282d31c32dfe076222d4a12277886",
"type": "github"
},
"original": {
"owner": "Gerg-L",
"repo": "spicetify-nix",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View File

@@ -17,6 +17,42 @@
# `dotfiles` input (this is the only source-override mechanism):
# inputs.dotfiles.url = "git+https://github.com/end-4/dots-hyprland?submodules=1&rev=<sha>";
};
# GTK4 screenshare picker with live window/monitor previews — replaces the
# bare Qt hyprland-share-picker bundled with XDPH. Needs submodules.
hyprland-preview-share-picker = {
url = "git+https://github.com/WhySoBad/hyprland-preview-share-picker?submodules=1";
inputs.nixpkgs.follows = "nixpkgs";
};
# Declarative Firefox extensions (rycee's firefox-addons, the set behind
# nur.repos.rycee.firefox-addons — avoids importing all of NUR). Imported
# as a source tree via callPackage rather than its flake output so the
# addons evaluate under the system nixpkgs (needed for allowUnfree, e.g.
# the 1Password extension).
firefox-addons = {
url = "gitlab:rycee/nur-expressions";
flake = false;
};
# Main UI font of the current end-4 dots. Their installer clones this repo
# directly (the font is Google-proprietary, so no distro packages it).
google-sans-flex = {
url = "github:end-4/google-sans-flex";
flake = false;
};
# Declarative Spicetify (Spotify client mods). The home-manager module
# installs its own wrapped Spotify — pkgs.spotify must NOT be added anywhere.
spicetify-nix = {
url = "github:Gerg-L/spicetify-nix";
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: {

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,7 +1,78 @@
{ inputs, pkgs, ... }:
{ config, inputs, lib, pkgs, ... }:
let
share-picker = inputs.hyprland-preview-share-picker.packages.${pkgs.stdenv.hostPlatform.system}.default;
# Substitutes the current wallpaper palette (matugen's colors.json) into the
# picker stylesheet template on every launch, so the picker re-themes with
# the wallpaper, then hands off to the real picker binary.
share-picker-themed = pkgs.writeShellScript "share-picker-themed" ''
dir="$HOME/.config/hyprland-preview-share-picker"
colors="$HOME/.local/state/quickshell/user/generated/colors.json"
if [ -f "$colors" ]; then
${pkgs.jq}/bin/jq -r 'to_entries[] | "s|@\(.key)@|\(.value)|g"' "$colors" \
| ${pkgs.gnused}/bin/sed -f - "$dir/style.css.in" > "$dir/generated.css" || true
else
cp -f "$dir/style.css.in" "$dir/generated.css"
fi
exec ${share-picker}/bin/hyprland-preview-share-picker "$@"
'';
# Icon-only derivation: copies the real Discord logo (scalable SVG) out of the
# discord package under the theme name `discord`, so the relabeled Vesktop
# launcher entry below shows the genuine Discord logo. The output is the raw
# SVG bytes with no store references, so `discord` itself stays OUT of the
# runtime closure — it's fetched only at build time and freed by GC.
discord-icon = pkgs.runCommand "discord-icon" { } ''
install -Dm644 \
"${pkgs.discord}/opt/Discord/modules/discord_desktop_core/app/images/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
{
imports = [ inputs.illogical-flake.homeManagerModules.default ];
imports = [
inputs.illogical-flake.homeManagerModules.default
inputs.spicetify-nix.homeManagerModules.spicetify
./spotify-ducking.nix
];
home.username = "zephrynis";
home.homeDirectory = "/home/zephrynis";
@@ -19,12 +90,388 @@
# hyprland.plugins = [ ]; # listOf package, loaded via hyprland's plugin mechanism
};
# illogical-flake wipes and recopies ~/.config/hypr on every switch, so the
# UK layout override must be re-appended after its copy step each time.
# custom/general.lua is sourced after hyprland/general.lua (kb_layout = "us").
home.activation.ukKeyboardLayout = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
hyprCustomGeneral="$HOME/.config/hypr/custom/general.lua"
if [ -f "$hyprCustomGeneral" ] && ! grep -q 'kb_layout = "gb"' "$hyprCustomGeneral"; then
cat >> "$hyprCustomGeneral" << 'EOF'
-- UK keyboard layout (appended by nix-flake, overrides hyprland/general.lua)
hl.config({ input = { kb_layout = "gb" } })
EOF
echo "Appended UK keyboard layout to hypr/custom/general.lua"
fi
'';
# Suppress the Hyprland update news popup (option lives under ecosystem
# since Hyprland 0.46); re-appended after every recopy like the layout.
home.activation.hyprlandNoUpdateNews = lib.hm.dag.entryAfter [ "ukKeyboardLayout" ] ''
hyprCustomGeneral="$HOME/.config/hypr/custom/general.lua"
if [ -f "$hyprCustomGeneral" ] && ! grep -q 'no_update_news' "$hyprCustomGeneral"; then
cat >> "$hyprCustomGeneral" << 'EOF'
-- Suppress update news popup (appended by nix-flake)
hl.config({ ecosystem = { no_update_news = true } })
EOF
echo "Appended no_update_news to hypr/custom/general.lua"
fi
'';
# Monitor layout (captured from nwg-displays output, 2026-07-18): AOC
# CQ27G2S at 165Hz on the left, 4K TV at 1.5x scale to its right.
# Overrides the mode=preferred/position=auto catch-all in hyprland/general.lua.
home.activation.hyprlandMonitorLayout = lib.hm.dag.entryAfter [ "hyprlandNoUpdateNews" ] ''
hyprCustomGeneral="$HOME/.config/hypr/custom/general.lua"
if [ -f "$hyprCustomGeneral" ] && ! grep -q 'hl.monitor' "$hyprCustomGeneral"; then
cat >> "$hyprCustomGeneral" << 'EOF'
-- Monitor layout (appended by nix-flake, from nwg-displays 2026-07-18)
hl.monitor({ output = "DP-2", mode = "2560x1440@165.0", position = "0x0", scale = "1" })
hl.monitor({ output = "DP-1", mode = "3840x2160@59.98", position = "2560x678", scale = "1.5" })
-- Workspace 1 stays on the 4K TV; cursor starts on the 165Hz AOC
hl.workspace_rule({ workspace = "1", monitor = "DP-1", default = true })
hl.on("hyprland.start", function ()
hl.dispatch(hl.dsp.focus({ monitor = "DP-2" }))
hl.dispatch(hl.dsp.cursor.move({ x = 1280, y = 720 }))
end)
EOF
echo "Appended monitor layout to hypr/custom/general.lua"
fi
'';
# Tailscale tray icon in the bar's systray (daemon + package are system-side
# in modules/tailscale.nix). custom/execs.lua is the dots' intended autostart
# hook, but it's wiped and recopied every switch like the rest of ~/.config/hypr.
home.activation.trayscaleAutostart = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
hyprCustomExecs="$HOME/.config/hypr/custom/execs.lua"
if [ -f "$hyprCustomExecs" ] && ! grep -q 'trayscale' "$hyprCustomExecs"; then
cat >> "$hyprCustomExecs" << 'EOF'
-- Tailscale tray icon (appended by nix-flake)
hl.on("hyprland.start", function ()
hl.exec_cmd("trayscale --hide-window")
end)
EOF
echo "Appended trayscale autostart to hypr/custom/execs.lua"
fi
'';
# illogical-flake's generated custom/env.lua forces QT_QPA_PLATFORMTHEME=qt6ct
# (its workaround for plasma-integration not being installed). desktop.nix now
# installs plasma-integration + Darkly, so restore upstream's "kde" — that
# applies the matugen-generated kdeglobals (Material You colors, Darkly style)
# to Qt apps like Prism Launcher. Appended after the regeneration each switch;
# later hl.env calls win.
home.activation.qtPlatformThemeKde = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
hyprCustomEnv="$HOME/.config/hypr/custom/env.lua"
if [ -f "$hyprCustomEnv" ] && ! grep -q 'kde-platform-theme' "$hyprCustomEnv"; then
cat >> "$hyprCustomEnv" << 'EOF'
-- kde-platform-theme: restore upstream platform theme (appended by nix-flake);
-- plasma-integration + Darkly are installed system-side (desktop.nix)
hl.env("QT_QPA_PLATFORMTHEME", "kde")
EOF
echo "Restored QT_QPA_PLATFORMTHEME=kde in hypr/custom/env.lua"
fi
'';
# Same override for non-Hyprland entry points (illogical-flake's environment.nix
# sets this to qt6ct)
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
# 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.
home.activation.reloadHyprland = lib.hm.dag.entryAfter [ "browserKeybindFirefox" ] ''
for instance in /run/user/$(id -u)/hypr/*/; do
[ -d "$instance" ] || continue
HYPRLAND_INSTANCE_SIGNATURE="$(basename "$instance")" \
/run/current-system/sw/bin/hyprctl reload >/dev/null 2>&1 || true
done
'';
# Disable the desktop background clock widget. config.json is only copied on
# first install (user edits persist), so this mainly matters for fresh
# installs; the guard keeps later manual edits to other keys untouched.
home.activation.disableBackgroundClock = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
iiConfig="$HOME/.config/illogical-impulse/config.json"
if [ -f "$iiConfig" ] && [ "$(${pkgs.jq}/bin/jq '.background.widgets.clock.enable' "$iiConfig")" != "false" ]; then
${pkgs.jq}/bin/jq '.background.widgets.clock.enable = false' "$iiConfig" > "$iiConfig.tmp" \
&& mv "$iiConfig.tmp" "$iiConfig"
echo "Disabled background clock widget in illogical-impulse config.json"
fi
'';
# The dots swap terminal copy/interrupt conventions: foot binds Ctrl+C to
# clipboard-copy and moves SIGINT (\x03) to Ctrl+Shift+C; kitty maps Ctrl+C
# to copy_or_interrupt (copies whenever text is selected); foot also puts
# paste on Ctrl+V. Restore standard behavior — Ctrl+C interrupts, copy/paste
# on Ctrl+Shift+C/V — re-applied after every recopy like the hypr overrides.
home.activation.footCtrlC = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
footIni="$HOME/.config/foot/foot.ini"
if [ -f "$footIni" ] && grep -q '^clipboard-copy=Control+c$' "$footIni"; then
${pkgs.gnused}/bin/sed -i \
-e 's/^clipboard-copy=Control+c$/clipboard-copy=Control+Shift+c/' \
-e 's/^clipboard-paste=Control+v$/clipboard-paste=Control+Shift+v/' \
-e '/^\\x03=Control+Shift+c$/d' \
"$footIni"
echo "Restored default Ctrl+C/Ctrl+V in foot.ini"
fi
'';
home.activation.kittyCtrlC = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
kittyConf="$HOME/.config/kitty/kitty.conf"
if [ -f "$kittyConf" ] && ! grep -q 'Restore default Ctrl+C' "$kittyConf"; then
cat >> "$kittyConf" << 'EOF'
# Restore default Ctrl+C (appended by nix-flake); copy is Ctrl+Shift+C
map ctrl+c
EOF
echo "Unmapped Ctrl+C in kitty.conf"
fi
'';
# Point xdg-desktop-portal-hyprland at the preview share picker. xdph.conf
# lives in ~/.config/hypr, which the recopy wipes, so recreate it each switch.
home.activation.xdphSharePicker = lib.hm.dag.entryAfter [ "copyIllogicalImpulseConfigs" ] ''
cat > "$HOME/.config/hypr/xdph.conf" << 'EOF'
# Written by nix-flake (recreated every switch; the dots wipe this dir)
screencopy {
custom_picker_binary = ${share-picker-themed}
}
EOF
'';
# GTK apps decide light/dark from this key (via the settings portal). The
# dots' Arch installer sets it imperatively; the flake port never does, so
# GTK apps came up light while the shell rendered dark.
dconf.settings."org/gnome/desktop/interface".color-scheme = "prefer-dark";
# Share picker: single-click selection and Material-You styling. The colors
# reference GTK named colors from ~/.config/gtk-4.0/gtk.css, which matugen
# regenerates from the wallpaper — so the picker re-themes automatically.
# (This dir isn't shipped by the dots, so plain xdg.configFile works.)
xdg.configFile."hyprland-preview-share-picker/config.yaml".text = ''
stylesheets:
- generated.css
windows:
clicks: 1
outputs:
clicks: 1
'';
# Template for the wrapper above: @token@ placeholders are replaced with hex
# values from matugen's colors.json (Material tokens, current light/dark set).
xdg.configFile."hyprland-preview-share-picker/style.css.in".text = ''
.window {
background-color: @surface@;
color: @on_surface@;
border: 1px solid @outline_variant@;
border-radius: 24px;
padding: 10px;
font-family: "Google Sans Flex", "Rubik", sans-serif;
font-size: 15px;
}
.page,
.notebook,
.notebook stack {
background: transparent;
}
.notebook header {
background: transparent;
border: none;
margin-bottom: 6px;
}
.notebook header tab {
border-radius: 9999px;
padding: 6px 18px;
margin: 4px 3px;
background: transparent;
color: @on_surface_variant@;
transition: background-color 150ms ease;
outline: none;
box-shadow: none;
}
.notebook header tab:hover {
background-color: @surface_container_high@;
}
.notebook header tab:checked {
background-color: @primary_container@;
color: @on_primary_container@;
}
.card {
background-color: @surface_container@;
color: @on_surface@;
border: 2px solid transparent;
border-radius: 18px;
padding: 6px;
transition: background-color 150ms ease, border-color 150ms ease;
}
.card:hover {
background-color: @surface_container_high@;
border-color: @primary@;
}
.card-loading {
background-color: @surface_container@;
border-radius: 18px;
}
.image {
border-radius: 12px;
background: transparent;
}
.image-label {
color: @on_surface_variant@;
font-size: 0.92em;
margin-top: 4px;
}
/* The outputs view wraps each preview card in a classless GtkButton whose
stock background shows as a white matte neutralize it and let the
inner .card carry the visuals. */
.page button:not(.region-button) {
background: none;
border: none;
box-shadow: none;
padding: 0;
outline: none;
}
.page button:not(.region-button):hover,
.page button:not(.region-button):active {
background: none;
}
/* `background` shorthand, not background-color: the theme's
background-image gradient must be reset too. */
.region-button {
background: @primary_container@;
color: @on_primary_container@;
border: none;
border-radius: 9999px;
padding: 14px 32px;
font-size: 1.1em;
outline: none;
box-shadow: none;
}
.region-button:hover {
background: @secondary_container@;
}
.restore-button {
background-color: @surface_container@;
color: @on_surface_variant@;
border: none;
border-radius: 9999px;
padding: 6px 14px;
margin-top: 8px;
}
.restore-button check {
background-color: @surface_container_highest@;
border: 1px solid @outline@;
border-radius: 6px;
}
.restore-button check:checked {
background-color: @primary@;
color: @on_primary@;
border-color: @primary@;
}
'';
# The illogical-flake fish module generates its own config.fish, dropping the
# dots' `set fish_greeting` — so the "Welcome to fish" banner came back.
programs.fish.interactiveShellInit = "set -g fish_greeting";
programs.git = {
enable = true;
userName = "zephrynis";
userEmail = "zephrynis.yt@gmail.com";
};
# The dots run `hyprctl setcursor Bibata-Modern-Classic 24` but don't install
# the theme (an assumed system dependency on Arch)
home.pointerCursor = {
name = "Bibata-Modern-Classic";
package = pkgs.bibata-cursors;
size = 24;
gtk.enable = true;
x11.enable = true;
};
# Two profiles: personal (default, purple) and work (blue). Firefox has no
# native per-profile accent, so each profile tints its toolbars via
# userChrome.css. Launch work with `firefox -P work` (desktop entry below).
programs.firefox = {
enable = true;
profiles =
let
addons = pkgs.callPackage "${inputs.firefox-addons}/pkgs/firefox-addons" {
# Same wiring as the upstream flake's overlay, but against the system
# pkgs so nixpkgs.config (allowUnfree, for 1Password) applies
buildMozillaXpiAddon =
(import "${inputs.firefox-addons}/lib/mozilla.nix" { inherit lib; })
.mkBuildMozillaXpiAddon { inherit (pkgs) fetchurl stdenv; };
};
settings = {
# Load userChrome.css
"toolkit.legacyUserProfileCustomizations.stylesheets" = true;
# Auto-enable the declaratively installed extensions
"extensions.autoDisableScopes" = 0;
};
accent = color: ''
#navigator-toolbox {
background-color: ${color} !important;
color: #f4f2fa !important;
}
'';
in
{
personal = {
id = 0;
isDefault = true;
inherit settings;
extensions.packages = [ addons.ublock-origin addons.proton-pass ];
userChrome = accent "#45256e";
};
work = {
id = 1;
inherit settings;
extensions.packages = [ addons.ublock-origin addons.onepassword-password-manager ];
userChrome = accent "#1e3a6e";
};
};
};
# Launcher for the work profile; plain `firefox` opens personal (the default)
xdg.desktopEntries.firefox-work = {
name = "Firefox (Work)";
genericName = "Web Browser";
exec = "firefox -P work --name firefox-work %U";
icon = "firefox";
terminal = false;
categories = [ "Network" "WebBrowser" ];
};
programs.direnv = {
enable = true;
nix-direnv.enable = true;
@@ -32,7 +479,109 @@
programs.vscode.enable = true;
# Spotify + Spicetify. The module installs its own wrapped Spotify, so
# pkgs.spotify is intentionally absent from home.packages. spicyLyrics is a
# first-class packaged extension (word-by-word karaoke lyrics view).
programs.spicetify =
let
spicePkgs = inputs.spicetify-nix.legacyPackages.${pkgs.stdenv.hostPlatform.system};
in
{
enable = true;
enabledExtensions = with spicePkgs.extensions; [
spicyLyrics
];
};
# File manager; gvfs/udisks2 backends are enabled system-side in desktop.nix.
# xdg-open and apps use this for directories. The claude-cli handler was
# registered imperatively by claude-code — kept here since HM now owns
# mimeapps.list.
# Relabel Vesktop's launcher entry to "Discord" with the real Discord logo.
# HM writes ~/.local/share/applications/vesktop.desktop, which shadows the
# package's own copy (user data dir wins in XDG_DATA_DIRS). Exec stays
# `vesktop`; StartupWMClass stays Vesktop so window matching still works.
xdg.desktopEntries.vesktop = {
name = "Discord";
genericName = "Internet Messenger";
exec = "vesktop %U";
icon = "discord";
categories = [ "Network" "InstantMessaging" "Chat" ];
mimeType = [ "x-scheme-handler/discord" ];
type = "Application";
settings = {
Keywords = "discord;vencord;electron;chat;";
StartupWMClass = "Vesktop";
};
};
# 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 = {
enable = true;
defaultApplications = {
"inode/directory" = "org.gnome.Nautilus.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";
};
};
home.packages = with pkgs; [
# 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.
gh
# sshm: TUI to manage and connect to SSH hosts (reads/writes ~/.ssh/config)
sshm
nautilus
# Vesktop: Discord+Vencord with a real Wayland/PipeWire screenshare — a
# sane WebRTC bitrate (the official `discord` client starved it into
# macroblocks) and it streams desktop audio, which official Linux Discord
# can't. Uses the same xdg-desktop-portal-hyprland picker wired up above.
# Relabeled to "Discord" with the real Discord logo via the desktop-entry
# override + discord-icon below.
vesktop
discord-icon
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
ripgrep
fd
fzf

View File

@@ -7,6 +7,8 @@
../../modules/desktop.nix
../../modules/nvidia.nix
../../modules/gaming.nix
../../modules/tailscale.nix
../../modules/noise-suppression.nix
];
networking.hostName = "nixos-pc";
@@ -31,8 +33,25 @@
};
programs.fish.enable = true;
# 1Password desktop app + CLI. The module (vs a plain package) sets up the
# polkit policy for system-auth unlock and the setgid onepassword group the
# browser integration needs.
programs._1password-gui = {
enable = true;
polkitPolicyOwners = [ "zephrynis" ];
};
programs._1password.enable = true;
# 1Password verifies the browser binary's name before connecting the
# extension to the app; NixOS's wrapped Firefox isn't on its allowlist
environment.etc."1password/custom_allowed_browsers" = {
text = ".firefox-wrapped";
mode = "0755";
};
time.timeZone = "Europe/London";
i18n.defaultLocale = "en_GB.UTF-8";
console.keyMap = "uk"; # TTYs
services.xserver.xkb.layout = "gb"; # SDDM greeter / X11; Hyprland is set in home/zephrynis.nix
# Set to the NixOS release of the ISO used at install time; never change afterwards.
system.stateVersion = "26.05";

View File

@@ -16,6 +16,9 @@
networking.networkmanager.enable = true; # required by illogical-flake
# Firmware blobs for Realtek RTL8821CU WiFi/Bluetooth dongle (and other devices)
hardware.enableRedistributableFirmware = true;
hardware.bluetooth = {
enable = true;
powerOnBoot = true;

View File

@@ -1,8 +1,11 @@
{ pkgs, ... }:
{ inputs, pkgs, ... }:
{
# System-side requirements of illogical-flake (the HM module does the rest)
programs.hyprland.enable = true;
programs.hyprland = {
enable = true;
withUWSM = false; # UWSM session black-screens (NVIDIA env vars + non-UWSM-aware dots)
};
services.geoclue2.enable = true; # QtPositioning — weather / night light widgets
services.displayManager.sddm = {
@@ -10,8 +13,29 @@
wayland.enable = true;
};
# Boot straight into Hyprland; the illogical-impulse lock screen acts as the
# login page (lock.launchOnStartup in ~/.config/illogical-impulse/config.json)
services.displayManager.autoLogin = {
enable = true;
user = "zephrynis";
};
services.displayManager.defaultSession = "hyprland";
xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-gtk ]; # file pickers etc.
# Qt app theming (Prism Launcher, protonup-qt, …), matching upstream Arch:
# the dots set QT_QPA_PLATFORMTHEME=kde and write wallpaper Material You
# colors + widgetStyle=Darkly into ~/.config/kdeglobals, assuming
# plasma-integration and Darkly are system-installed (like Bibata). The
# illogical-flake port overrides the platform theme to qt6ct in
# hypr/custom/env.lua — countered in home/zephrynis.nix. No qt.style here:
# QT_STYLE_OVERRIDE would beat kdeglobals' widgetStyle.
qt = {
enable = true;
platformTheme = "kde";
};
environment.systemPackages = [ pkgs.darkly ]; # Qt6 only; no qt5 variant in nixpkgs
security.polkit.enable = true;
programs.dconf.enable = true;
services.gvfs.enable = true;
@@ -19,6 +43,16 @@
services.upower.enable = true;
fonts.packages = with pkgs; [
# Fonts the current dots actually use (config.json appearance.fonts):
# main/title/numbers = Google Sans Flex, reading = Readex Pro,
# expressive = Space Grotesk. Without these everything silently falls
# back to DejaVu Sans.
(runCommand "google-sans-flex-font" { } ''
install -Dm644 ${inputs.google-sans-flex}/*.ttf -t $out/share/fonts/truetype
'')
readexpro
(google-fonts.override { fonts = [ "SpaceGrotesk" ]; })
# named by the illogical-flake README
rubik
nerd-fonts.ubuntu

View File

@@ -1,6 +1,8 @@
{ pkgs, ... }:
{ inputs, pkgs, ... }:
{
imports = [ inputs.nix-flatpak.nixosModules.nix-flatpak ];
programs.steam = {
enable = true;
remotePlay.openFirewall = true;
@@ -14,6 +16,48 @@
environment.systemPackages = with pkgs; [
mangohud
prismlauncher
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"
'';
})
];
}

View File

@@ -0,0 +1,74 @@
{ pkgs, ... }:
# App-independent microphone noise suppression via PipeWire + RNNoise.
#
# Replaces Discord's Krisp (which can't be enabled on current nixpkgs Discord:
# the binary is patchelf'd, and even after bypassing Krisp's signature check the
# newer "THz SDK" stage fails with KRISP_INIT_ERROR_GLOBAL_INIT). Instead we run
# RNNoise as a PipeWire filter-chain that exposes a cleaned virtual microphone.
#
# This works in EVERY app (Discord, browser, OBS, games), survives Discord
# updates, and touches no DRM. In each app's input settings, pick the source
# named "Noise Canceling source" instead of the raw mic.
{
# LADSPA plugin providing noise_suppressor_mono/stereo (werman RNNoise).
environment.systemPackages = [ pkgs.rnnoise-plugin ];
services.pipewire.extraConfig.pipewire."99-input-denoising" = {
"context.modules" = [
{
name = "libpipewire-module-filter-chain";
args = {
"node.description" = "Noise Canceling source";
"media.name" = "Noise Canceling source";
"filter.graph" = {
nodes = [
{
type = "ladspa";
name = "rnnoise";
plugin = "${pkgs.rnnoise-plugin}/lib/ladspa/librnnoise_ladspa.so";
label = "noise_suppressor_mono";
control = {
# Higher threshold = more aggressive gating of non-voice.
# 50% is a balanced starting point; raise toward 9095 if a
# noisy background still leaks through, lower if speech clips.
"VAD Threshold (%)" = 50.0;
"VAD Grace Period (ms)" = 200.0;
"Retroactive VAD Grace (ms)" = 0.0;
};
}
{
# Makeup gain baked into the graph so the source volume is
# reproducible instead of living only in WirePlumber's runtime
# state. Mult = 1.53 matches the 153% level set in the mixer.
type = "builtin";
name = "gain";
label = "linear";
control = {
"Mult" = 1.53;
"Add" = 0.0;
};
}
];
links = [
{ output = "rnnoise:Output"; input = "gain:In"; }
];
};
# The virtual mic apps select. media.class = Audio/Source makes it
# appear as a capture device throughout the desktop.
"capture.props" = {
"node.name" = "capture.rnnoise_source";
"node.passive" = true;
"audio.rate" = 48000;
};
"playback.props" = {
"node.name" = "rnnoise_source";
"media.class" = "Audio/Source";
"audio.rate" = 48000;
};
};
}
];
};
}

15
modules/tailscale.nix Normal file
View File

@@ -0,0 +1,15 @@
{ pkgs, ... }:
{
services.tailscale = {
enable = true;
# Let zephrynis drive the daemon without sudo — required for Trayscale,
# and makes `tailscale switch` / `login` work from a plain shell too
extraSetFlags = [ "--operator=zephrynis" ];
};
# Tray app for the bar's systray: status toggle + account switcher
# (work/personal via Tailscale fast user switching). Autostarted from
# hypr/custom/execs.lua (appended in home/zephrynis.nix).
environment.systemPackages = [ pkgs.trayscale ];
}