commit aee8fb1e9b37e67a96dc20e4ca3968f3a3c18e1e Author: Peter Edley Date: Sat Sep 19 13:56:12 2026 +0100 Nix-Vibe public snapshot (squashed history) diff --git a/.github/skills/flake-update/SKILL.md b/.github/skills/flake-update/SKILL.md new file mode 100644 index 0000000..35f55d0 --- /dev/null +++ b/.github/skills/flake-update/SKILL.md @@ -0,0 +1,82 @@ +--- +name: flake-update +description: 'Safely update flake inputs: ensure a clean committed/pushed working tree, run nix flake update, then dry-build EVERY host before committing flake.lock. Use when the user asks to update the flake, refresh flake.lock, bump inputs, or upgrade nixpkgs/dependencies.' +--- + +# Flake Update + +## When to Use +- When the user asks to "update the flake", "bump inputs", "refresh flake.lock", or "update nixpkgs" +- Before a planned fleet-wide input upgrade + +## Procedure + +### 1. Clean Working Tree (MANDATORY) + +Never run `nix flake update` with uncommitted changes — mixing unrelated edits with a lockfile bump makes rollback painful. + +```bash +git status +``` + +- If there are uncommitted changes: validate and commit them first (follow the **nix-flake-rebuild** skill: stage, `nixpkgs-fmt .`, `nix flake check`, dry-build affected hosts, commit). +- Then push everything to the remote: + +```bash +git push +``` + +Only proceed when `git status` is clean and the branch is fully pushed. + +### 2. Update Inputs + +```bash +# All inputs +nix flake update + +# OR a single input, if the user asked for one +nix flake update +``` + +### 3. Flake Check + +```bash +nix flake check +``` + +Fix any evaluation errors before building. Input bumps often surface deprecated/renamed NixOS and Home Manager options — check warnings and refer to release notes for replacements. + +### 4. Dry-Build EVERY Host + +An input update affects the whole fleet — dry-build every host, no exceptions. Generate the host list dynamically from the flake so new hosts are never missed: + +```bash +for host in $(nix eval .#nixosConfigurations --apply 'attrs: builtins.concatStringsSep " " (builtins.attrNames attrs)' --raw); do + echo "=== $host ===" + nixos-rebuild dry-build --flake .#$host || break +done +``` + +If any host fails, do NOT commit. Either fix the breakage or roll back (see below). + +### 5. Commit & Push Lockfile (only if ALL hosts pass) + +```bash +git add flake.lock +git commit -m "flake: update inputs" +git push +``` + +## Rollback + +Because step 1 guaranteed a clean, pushed tree, the previous lockfile is always recoverable: + +```bash +git restore --source=HEAD~1 flake.lock +``` + +Or check out an older known-good lockfile from history. + +## Notes +- This skill only **validates** the update. Actually applying it (`nixos-rebuild switch`) is a separate, per-host step. +- Review the `git diff flake.lock` before committing if the user wants to know which inputs moved. diff --git a/.github/skills/nix-flake-rebuild/SKILL.md b/.github/skills/nix-flake-rebuild/SKILL.md new file mode 100644 index 0000000..a3cae6d --- /dev/null +++ b/.github/skills/nix-flake-rebuild/SKILL.md @@ -0,0 +1,90 @@ +--- +name: nix-flake-rebuild +description: 'Validate Nix Flake changes end-to-end: stage, check, dry-build, and commit. Use after ANY change to .nix files, flake inputs, or module imports. Use when the user asks to apply, test, validate, rebuild, or commit NixOS config changes.' +argument-hint: '[hostname]' +--- + +# Nix Flake Rebuild Validation + +## When to Use +- After ANY modification to `.nix` files, `flake.nix`, or module imports +- Before applying configs with `nixos-rebuild switch` +- When the user asks to "test", "validate", "rebuild", or "apply" changes +- After adding new files, modules, or hosts + +## Critical Pre-Check: Git Staging + +Nix flakes ONLY see Git-tracked files. If you created new files or directories, **you must stage them first**: +```bash +git add +``` +Failure to do this will cause `nix flake check` to fail with confusing "file not found" errors. + +## Procedure + +### 1. Sync Documentation (if needed) +If software was added/removed or a host role changed, update the software inventory table and host overview in `README.md`. + +### 2. Stage ALL Changes +```bash +git add -A +``` +This ensures flake evaluation can see every file. + +### 3. Format Code +```bash +nixpkgs-fmt . +``` + +### 4. Run Flake Check +```bash +nix flake check +``` +Fix any errors before proceeding. Common issues: +- Missing `git add` on new files +- Deprecated NixOS/Home Manager options +- Syntax errors in `.nix` files + +### 5. Dry-Build for Affected Hosts +```bash +nixos-rebuild dry-build --flake .# +``` +Run for EACH host affected by the changes. If unsure which hosts are affected, run for all. Generate the host list dynamically from the flake so new hosts are never missed: +```bash +for host in $(nix eval .#nixosConfigurations --apply 'attrs: builtins.concatStringsSep " " (builtins.attrNames attrs)' --raw); do + echo "=== $host ===" + nixos-rebuild dry-build --flake .#$host || break +done +``` + +### 6. Commit (only if ALL checks pass) +```bash +git commit -m "descriptive message" +``` +Do NOT commit if `nix flake check` or any dry-build failed. + +## Host Reference + +> Informational only — the dry-build loop above generates the host list dynamically and is authoritative. Keep this table roughly in sync when adding hosts (used to check which overlay a host gets). + +| Host | Type | Overlay | +|------|------|---------| +| `x1carbon` | Desktop/laptop | Full (`overlays`: CUDA/NDI/stable) | +| `caitlin-x1` | Desktop/laptop | `desktopOverlays` | +| `x470` | Desktop/laptop | `desktopOverlays` | +| `mary-x270` | Desktop | `desktopOverlays` | +| `richmond-server` | Server | `serverOverlays` | +| `homeserver-1` | Server | `serverOverlays` | +| `mcf-server` | Server | `serverOverlays` | +| `mcf-stream` | Desktop/laptop | `desktopOverlays` | +| `hp-laptop` | Desktop/laptop | `desktopOverlays` | + +## Post-Validation +After a successful commit, to actually apply: +```bash +# Local +sudo nixos-rebuild switch --flake .# + +# Remote +nixos-rebuild switch --target-host --flake .# --use-remote-sudo +``` diff --git a/.github/skills/nix-module/SKILL.md b/.github/skills/nix-module/SKILL.md new file mode 100644 index 0000000..d610756 --- /dev/null +++ b/.github/skills/nix-module/SKILL.md @@ -0,0 +1,132 @@ +--- +name: nix-module +description: 'Create or update a NixOS or Home Manager module following Nix-Vibe conventions. Use when adding a new service, desktop app, hardware support, or shared configuration module. Use when the user asks to add a package, service, or application module.' +argument-hint: ' [service|desktop|hardware|core]' +--- + +# Nix Module Creation + +## When to Use +- Adding a new service (e.g., a new containerized app, daemon, or web service) +- Adding a new desktop application module +- Adding hardware support (e.g., new device driver, firmware) +- Creating a new shared core module +- The user asks to "add support for X" or "create a module for X" + +## Module Pattern + +All service/feature modules MUST follow this pattern: + +```nix +{ config, pkgs, lib, ... }: + +{ + options.services. = { + enable = lib.mkEnableOption "description of the service"; + # Additional options as needed + }; + + config = lib.mkIf config.services..enable { + # Configuration here + }; +} +``` + +Reference implementation: [modules/services/immich.nix](../../modules/services/immich.nix) + +## Procedure + +### 1. Determine Module Type + +| Type | Directory | Example | +|------|-----------|---------| +| Service (daemon, container, webapp) | `modules/services/` | immich, jellyfin, ntfy | +| Desktop app | `modules/desktop/apps/` | soundux, opencode, freeshow | +| Desktop environment | `modules/desktop/` | gnome.nix, gui.nix | +| Hardware support | `modules/hardware/` | fingerprint.nix | +| Core system config | `modules/core/` | common.nix, settings.nix, fonts.nix | + +### 2. Create the Module File + +#### Service Module Template +```nix +{ config, pkgs, lib, ... }: + +{ + options.services. = { + enable = lib.mkEnableOption ""; + port = lib.mkOption { + type = lib.types.port; + default = ; + description = "Port for "; + }; + # Add data directories, user config, etc. as needed + }; + + config = lib.mkIf config.services..enable { + # Service-specific configuration + + # For stateful services, ensure data directories exist + systemd.tmpfiles.rules = [ + "d /var/lib/ 0700 -" + ]; + + # Open firewall if needed + networking.firewall.allowedTCPPorts = [ config.services..port ]; + }; +} +``` + +#### Desktop App Module Template +```nix +{ pkgs, ... }: + +{ + environment.systemPackages = with pkgs; [ + + ]; +} +``` + +### 3. Register in Host Configuration + +Import the module in the target host's `configuration.nix`: +```nix +imports = [ + # ...existing imports... + ../../modules/services/.nix +]; +``` + +Then enable it: +```nix +services..enable = true; +``` + +### 4. Update README.md +Add the new software to the Software Inventory table with the appropriate host columns. + +### 5. Validate +Run the full validation workflow. See [nix-flake-rebuild](../nix-flake-rebuild/SKILL.md). + +## Key Conventions + +### Secrets +Never hardcode secrets. Use SOPS: +```nix +sops.secrets."/" = { + owner = ""; + group = "users"; + mode = "0440"; +}; +``` +See [docs/sops-secrets.md](../../docs/sops-secrets.md) for details. + +### Overlay Awareness +- If a package needs NVIDIA/CUDA or NDI, it should only be added to `x1carbon` (the only host using the full `overlays` set) +- Desktops/laptops use `desktopOverlays` (`stable` without CUDA); servers use `serverOverlays` + +### Module Composition +- Core modules (`modules/core/common.nix`) apply to ALL hosts +- Desktop modules only apply to GUI hosts +- Service modules are imported per-host as needed diff --git a/.github/skills/nix-new-host/SKILL.md b/.github/skills/nix-new-host/SKILL.md new file mode 100644 index 0000000..6f7f9d2 --- /dev/null +++ b/.github/skills/nix-new-host/SKILL.md @@ -0,0 +1,246 @@ +--- +name: nix-new-host +description: 'Scaffold a new NixOS host in this flake: create host directory, config files, wire into flake.nix, and set up Home Manager users. Use when adding a new machine (desktop, laptop, or server) to the Nix-Vibe configuration.' +argument-hint: ' [desktop|server]' +--- + +# New Host Scaffolding + +## When to Use +- Adding a brand new machine to the Nix-Vibe configuration +- User asks to "add a host", "create a new machine config", or "scaffold a server/laptop" +- Migrating a new device into this flake-based setup + +## Procedure + +### 1. Gather Information +Before creating files, confirm with the user: +- **Hostname** (e.g., `new-laptop`) +- **Type**: Desktop/laptop (needs GUI) or Server (headless, CLI only) +- **Users** who will have Home Manager configs on this host +- **Any special hardware** (NVIDIA GPU, fingerprint sensor, etc.) +- **Any services** this host should run + +### 2. Create Host Directory +```bash +mkdir -p hosts/ +``` + +### 3. Create Required Host Files + +#### `hosts//hardware-configuration.nix` +Generate on the target machine after NixOS install: +```bash +nixos-generate-config --show-hardware-config > hosts//hardware-configuration.nix +``` +If the target machine isn't available yet, create a minimal placeholder: +```nix +# hardware-configuration.nix for +# Generated placeholder — run nixos-generate-config on the target machine +{ + config, + lib, + pkgs, + ... +}: +{ + imports = [ ]; + boot.initrd.availableKernelModules = [ ]; + boot.initrd.kernelModules = [ ]; + boot.kernelModules = [ ]; + fileSystems."/" = { + device = "/dev/disk/by-label/nixos"; + fsType = "ext4"; + }; + swapDevices = [ ]; + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; +} +``` + +#### `hosts//disko-config.nix` +Copy from an existing host of the same type (desktop vs server) and adjust disk layout: +```bash +cp hosts/x1carbon/disko-config.nix hosts//disko-config.nix +# Then edit to match the target disk layout +``` + +#### `hosts//configuration.nix` +Use the appropriate template below. + +**Desktop/laptop template:** +```nix +{ + config, + pkgs, + inputs, + lib, + ... +}: +{ + imports = [ + ./hardware-configuration.nix + ../../modules/core/common.nix + ../../modules/desktop/gui.nix + (import ../../modules/storage/disko.nix { + inherit inputs lib config; + diskoConfigPath = ./disko-config.nix; + }) + ../../modules/desktop/gnome.nix + ../../modules/core/management.nix + ../../modules/core/podman.nix + ../../modules/core/dev.nix + ]; + + networking.hostName = ""; + + hardware.graphics.enable = true; + hardware.graphics.enable32Bit = true; +} +``` + +**Server template:** +```nix +{ + config, + pkgs, + lib, + inputs, + ... +}: +{ + imports = [ + ../../modules/core/common.nix + (import ../../modules/storage/disko.nix { + inherit inputs lib config; + diskoConfigPath = ./disko-config.nix; + }) + ./hardware-configuration.nix + ../../modules/core/podman.nix + ]; + + networking.hostName = ""; +} +``` + +### 4. Wire into `flake.nix` + +#### Add users in `hostUsers`: +```nix +hostUsers = { + # ...existing hosts... + = [ "user1" "user2" ]; +}; +``` + +#### Add nixosConfiguration: +If **desktop/laptop** (uses full overlays): +```nix +nixosConfigurations = { + # ...existing configs... + = mkNixosSystem "" { }; +}; +``` + +If **server** (uses serverOverlays): +```nix +nixosConfigurations = { + # ...existing configs... + = mkNixosSystem "" { + hostOverlays = serverOverlays; + }; +}; +``` + +### 5. Create Home Manager User Configs (if needed) +If the host has users without existing Home Manager configs, create: +```bash +mkdir -p home-manager/users +``` + +Minimal user template (`home-manager/users/.nix`): +```nix +{ + config, + pkgs, + lib, + inputs, + ... +}: +{ + home-manager.users. = { + home.username = ""; + home.homeDirectory = "/home/"; + home.stateVersion = "24.11"; + + programs.home-manager.enable = true; + programs.git.enable = true; + }; +} +``` + +### 6. Update README.md +Add the new host to: +- The **Host Roles** section with role description and key features +- The **Software Inventory** table (add a column for the new host) + +### 7. Register Host in nix-flake-rebuild Skill +The dry-build loop in [nix-flake-rebuild](../nix-flake-rebuild/SKILL.md) is dynamic (hosts are read from `nixosConfigurations`), so nothing breaks if you skip this — but add `` to its **Host Reference** table (with the correct Type and Overlay — `serverOverlays` for servers, Full for desktops/laptops) to keep the overlay reference accurate. + +### 8. Validate +Follow the [nix-flake-rebuild](../nix-flake-rebuild/SKILL.md) skill to validate all changes. + +## Common Patterns + +### Adding Desktop Apps +Import app modules in the host config: +```nix +imports = [ + # ...existing imports... + ../../modules/desktop/apps/soundux.nix + ../../modules/desktop/apps/opencode.nix +]; +``` + +### Adding Services +Import service modules and enable: +```nix +imports = [ + # ...existing imports... + ../../modules/services/immich.nix +]; +services.immich-server.enable = true; +``` + +### Hardware-Specific Modules +```nix +imports = [ + # ...existing imports... + ../../modules/hardware/fingerprint.nix # if fingerprint reader + ../../modules/hardware/nvidia.nix # if NVIDIA GPU +]; +``` + +For an NVIDIA GPU, enable via the shared option (instead of hand-writing +`hardware.nvidia`): +```nix +my.hardware.nvidia = { + enable = true; + nvidiaSettings = true; + package = config.boot.kernelPackages.nvidiaPackages.legacy_580; # optional +}; +``` + +### Shared Users (petere) + +The admin user `petere` is defined once in `modules/core/users.nix` (imported via +`common.nix`). New hosts do **not** re-declare the full `users.users.petere` +block — override only what differs: +```nix +my.users.petere = { + hashedPasswordFile = config.sops.secrets."users/petere-password".path; + subUidStart = 165536; # optional rootless-podman range + subGidStart = 165536; +}; +``` +`petere` also needs a Home Manager config in `hostUsers` (step 4). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b6ea4a --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Nix build results +result* + +# Tailscale authkey (unencrypted, should NOT be committed) +hosts/richmond-server/tailscale_authkey.txt +hosts/richmond-server/mcf-notices.env +hosts/richmond-server/castopod-container.env +hosts/richmond-server/castopod-api.env +hosts/richmond-server/pihole.env + +# Local sops age keys (generated per-machine at deploy time) +# The public key goes in .sops.yaml, encrypted secrets go in secrets.yaml +/root/.config/sops/age/ +/var/lib/sops-nix/ + +# Decrypted temporary sops files +.*decrypted* + +extra-files/ +extra-files/** +extra-files/ +extra-files/** diff --git a/.sops.yaml b/.sops.yaml new file mode 100644 index 0000000..2b94a19 --- /dev/null +++ b/.sops.yaml @@ -0,0 +1,12 @@ +creation_rules: + - path_regex: secrets\.yaml$ + age: age145xh9ecu2hye2r9s9lqgap49vydttwyhhfc4x93juy9d78f92pnsucj8wg, + age17z3fuzlfmerpnsrum9g4sfmkmgghtlltfq07lp79uzugm5are3cs64dnqy, + age1wzt34k82v2shr443zqmkfu2la8ewdfszwg8s93mqh8m6n7mfd3ssfegyd0, + age1ge9zg2kz80xyq9xk94kc70g3d44j3lurpclgg8cel8u3skmsx4kss6ny8a, + age10p0dv642mdlwzt0xgrrz6udndu0yg939hletl2zxk558ycufxypqp6qcve, + age1kra9xjk5709jluhdwvn2x6jczx26z5z2ffygsf0q3p2tvqvnff4shn345g, + age1k23adf45f8ay5g65axc4v8ahfeuq2gg7fmjw3vhaf4zl30a0c52sw4fhza, + age16d5anx997as6syyzzj0cs70kvsknl2pjs4afx7l77fjpawp7vy2qnvm458, + age14kdra7c48a9262vxtsrmu09lhk6xt05hre560s89td46aadwpulq5esjqy, + age17sc2wm2zr8q84knuzr0jdhxasmt6wvvncgxuduqxz8h0juequfmqmw363q \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..41b88c9 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "signage.vscode-sops" + ] +} \ No newline at end of file diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 0000000..fe883bc --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,8 @@ +{ + "servers": { + "nixos": { + "command": "mcp-nixos", + "args": [] + } + } +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..85ab945 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "sops.ageKeyFile": "${env:HOME}/.config/sops/age/keys.txt", + "sops.encryptOnSave": true, + "sops.yaml.keyPaths": [ + ".sops.yaml" + ], + "chat.tools.terminal.autoApprove": { + "nix": true + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..45f11fc --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,92 @@ +# AGENTS.md — Nix-Vibe AI Agent Instructions + +A Nix Flakes-based multi-host NixOS + Home Manager configuration repository. + +## Quick Reference + +- **Build/Test**: `nix flake check` (must `git add` new files first) +- **Task runner**: `just` (see `justfile`) — `just` deploys a host + restarts quickshell, `just check`, `just dry-build`, `just test`, `just restart-qs` +- **Format**: `nixpkgs-fmt .` (or `nix fmt` — the flake defines a `nixpkgs-fmt` formatter) +- **Dev shell**: `nix develop` (provides `sops`, `age`, `nixpkgs-fmt`) +- **Apply**: `sudo nixos-rebuild switch --flake .#` +- **Test (no activation)**: `sudo nixos-rebuild test --flake .#` +- **Remote apply**: `nixos-rebuild switch --target-host --flake .# --use-remote-sudo` +- **Dry-build**: `nixos-rebuild dry-build --flake .#` + +## Architecture + +| Directory | Purpose | +|-----------|---------| +| `hosts//` | Per-machine configs (`configuration.nix`, `disko-config.nix`, `hardware-configuration.nix`) | +| `modules/core/` | Shared NixOS modules (common, fonts, settings, sops, podman, dev, users, management, known-hosts) | +| `modules/desktop/` | GUI desktop modules and app-specific modules | +| `modules/hardware/` | Hardware-specific modules (fingerprint, nvidia) | +| `modules/services/` | Service modules (immich, jellyfin, ntfy, paperless, immich-proxy) | +| `modules/storage/` | Disk configuration via Disko | +| `home-manager/modules/` | Shared Home Manager modules (zsh, gnome, firefox, kitty, etc.) | +| `home-manager/users/` | Per-user Home Manager configs | +| `flake.nix` | Entry point — defines `nixosConfigurations` via `mkNixosSystem` helper | + +Host overview and software inventory: see [README.md](./README.md). + +## Critical Rules + +### Style +- **Language**: Nix — follow conventions used in the `nixpkgs` repository. +- **Formatter**: `nixpkgs-fmt .` (run before every commit). + +### Git & Flakes +- **NEW FILES MUST BE STAGED** (`git add`) before `nix flake check` or `nixos-rebuild`. Flake evaluation only sees Git-tracked files. +- Do NOT commit until `nix flake check` AND a dry-build pass. + +### Module Pattern +- New service modules follow the pattern: `lib.mkEnableOption` for `enable` + `lib.mkIf config.services..enable { ... }`. +- Example: [modules/services/immich.nix](./modules/services/immich.nix) + +### Secrets +- Secrets are managed via [SOPS + age](./docs/sops-secrets.md) in `secrets.yaml`. +- Reference in host configs as `sops.secrets."" = { ... };`. +- Rendered config files (e.g. container env files) use `sops.templates` — see [docs/sops-secrets.md](./docs/sops-secrets.md). + +### Sudo +- Passwordless sudo is granted to **`petere` only** via `security.sudo.extraRules` (NOPASSWD) in `modules/core/common.nix`. All other wheel users must enter a password for `sudo`. + +### Zsh +- `programs.zsh.enable = true` must be set in NixOS config for Zsh users. +- Shared Zsh config uses `home-manager.sharedModules` in `flake.nix` — do NOT use `home.file.".zshrc"`. + +### Servers vs Desktops +- Servers (`richmond-server`, `homeserver-1`, `mcf-server`) use `serverOverlays` (lightweight). +- Desktops/laptops use `desktopOverlays` (`stable` without CUDA) by default. +- Only `x1carbon` uses the full `overlays` set with CUDA, NDI, stable packages (OBS Studio). + +### Deprecated Options +- Pay attention to warnings about deprecated options during `nix flake check` or `nixos-rebuild`. Refer to NixOS/Home Manager release notes for updated options. Keeping configurations current prevents surprises on channel updates. + +### Homepage Dashboard (gethomepage.dev) +- Dashboard definition for homeserver-1 lives in `hosts/homeserver-1/homepage.nix` (imported by the host config), NOT in `configuration.nix`. +- Full guide for adding machines/tabs/services: [docs/homepage-dashboard.md](./docs/homepage-dashboard.md). +- Widget group names MUST be unique — the credentialed proxy resolves config by leaf group name; duplicate names cause one machine's stats to render on another's tiles. +- Every Glances widget tile needs a `metric` field (`info`, `cpu`, `memory`, `fs:/`, `process`, ...) and `version = 4`; omitting `metric` throws `t.metric is undefined`. +- Tailscale widget `deviceid` must be the **numeric** device ID (from the Tailscale API), not the `...CNTRL` value. +- API keys are injected as `HOMEPAGE_VAR_*` env vars from the SOPS secret `homeserver-1/homepage-env` — never hardcode keys in `homepage.nix`. +- After editing `homepage.nix`, if changes don't appear after rebuild, restart the service: `sudo systemctl restart homepage-dashboard` (config files are only read at service start). +- Homepage is exposed on Tailscale only (port 8082). + +## Post-Modification Workflow + +1. Update `README.md` software inventory and host overview if relevant +2. When adding a new host, add it to the Host Reference table in `.github/skills/nix-flake-rebuild/SKILL.md` (informational; the dry-build loop is dynamic) +3. `git add` all modified/new files +4. `nix flake check` +5. `nixos-rebuild dry-build --flake .#` for affected hosts +6. Commit only after both checks pass + +## Documentation + +- [README.md](./README.md) — Full architecture, host roles, and entry point for all docs +- [docs/software-inventory.md](./docs/software-inventory.md) — Cross-reference matrix of software per host +- [docs/installation.md](./docs/installation.md) — nixos-anywhere deployment guide +- [docs/sops-secrets.md](./docs/sops-secrets.md) — SOPS/age secrets management +- [docs/borg-backup-setup.md](./docs/borg-backup-setup.md) — BorgBackup server setup (deprecated - migrated to Backrest) +- [docs/homepage-dashboard.md](./docs/homepage-dashboard.md) — Homepage dashboard: adding machines, tabs & services diff --git a/README.md b/README.md new file mode 100644 index 0000000..27280dd --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# Nix-Vibe: Multi-Host NixOS Configuration + +This repository contains a modular NixOS and Home Manager configuration managed via Nix Flakes. The architecture is designed for reproducibility and consistency across diverse hardware, from powerful desktops to headless servers. + +## Architecture Overview + +The system is built on a modular foundation, separating core configurations, hardware-specific settings, and user environments. + +### Host Roles + +* **`x1carbon`**: Advanced mobile workstation. + * **Role**: Portable audio engineering and personal document management. + * **Key Features**: Specialized audio control (X32-Edit, Mixing Station), Paperless-ngx, image editing (GIMP), desktop publishing (Scribus), 2-in-1 tablet mode (auto-rotation via accelerometer + on-screen keyboard via wvkbd). +* **`caitlin-x1`**: User-focused laptop. + * **Role**: Daily productivity and gaming. + * **Key Features**: Steam integration, Prism Launcher for Minecraft, and Soundux for audio management. +* **`x470`**: Lightweight guest/general use laptop. + * **Role**: Basic productivity and remote network management. + * **Key Features**: Winbox for networking and FreeShow for presentations. +* **`mary-x270`**: User-focused desktop workstation. + * **Role**: Daily productivity workstation for Mary. + * **Key Features**: Shared GNOME desktop environment, Home Manager configuration, and multi-user access (`mary` & `petere`). +* **`richmond-server`**: Headless infrastructure core. + * **Role**: Network services, container hosting, and central backup storage. + * **Key Features**: Podman-hosted services (Pi-hole, Castopod, MCFNotices), Backrest backup server, and complex VLAN networking. +* **`homeserver-1`**: Headless general server. + * **Role**: Auxiliary network server and container environment. + * **Key Features**: Tailscale, Podman, Zsh, Disko, SOPS secret management, Immich, Jellyfin, Backrest, Pocket ID, Gitea (git hosting, public at `gitea.edley.me`), and a Homepage dashboard. + +* **`mcf-server`**: Headless server. + * **Role**: Remote backup target for `homeserver-1` (restic). + * **Key Features**: SSH and Glances monitoring on the tailnet. +* **`mcf-stream`**: Desktop. + * **Role**: Newly scaffolded desktop host. + * **Key Features**: GNOME desktop, Home Manager for `petere`. +* **`hp-laptop`**: End-user laptop. + * **Role**: Daily productivity laptop for `petere`. + * **Key Features**: Hyprland + QuickShell desktop (no GNOME), greetd/tuigreet login, Home Manager for `petere`. + +--- + +### Software Inventory + +See the full cross-reference table in **[docs/software-inventory.md](docs/software-inventory.md)**. + +--- + +## Homepage Dashboard + +The Homepage dashboard (gethomepage.dev) runs on `homeserver-1` at +`http://homeserver-1:8082` and displays services from across the tailnet. + +- Dashboard definition: `hosts/homeserver-1/homepage.nix` +- Adding machines, tabs, and services: **[docs/homepage-dashboard.md](docs/homepage-dashboard.md)** +- Widget API keys are managed via SOPS (`secrets.yaml` → `HOMEPAGE_VAR_*` env vars) + +--- + +## Initial Installation + +This repository uses [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) for deployment to new hardware. See **[docs/installation.md](docs/installation.md)** for prerequisites and step-by-step instructions. + +--- + +## Managing Secrets + +This project uses [sops-nix](https://github.com/Mic92/sops-nix) and `age` to manage secrets. + +For instructions on adding new secrets, managing keys, and using secrets in your configuration, please see the **[SOPS Secrets Management Guide](docs/sops-secrets.md)**. diff --git a/assets/wallpapers/Scifi.jpg b/assets/wallpapers/Scifi.jpg new file mode 100644 index 0000000..37aa9d2 Binary files /dev/null and b/assets/wallpapers/Scifi.jpg differ diff --git a/assets/wallpapers/neon-horizon.png b/assets/wallpapers/neon-horizon.png new file mode 100644 index 0000000..27530ef Binary files /dev/null and b/assets/wallpapers/neon-horizon.png differ diff --git a/assets/wallpapers/tokyo-night.png b/assets/wallpapers/tokyo-night.png new file mode 100644 index 0000000..3cbd5a8 Binary files /dev/null and b/assets/wallpapers/tokyo-night.png differ diff --git a/docs/borg-backup-setup.md b/docs/borg-backup-setup.md new file mode 100644 index 0000000..bc57608 --- /dev/null +++ b/docs/borg-backup-setup.md @@ -0,0 +1,105 @@ +# Borg Backup Server Setup for Richmond-Server + +This document outlines the research and recommended approach for setting up a secure Borg backup server on the NixOS host `richmond-server`. + +## 1. Research Findings + +The core security principle for a Borg server is to use SSH with a dedicated, unprivileged user account whose command execution is strictly limited to `borg serve`. I investigated two main ways to achieve this on your server. + +### Method 1: Manual NixOS Configuration (Recommended) + +This method involves declaratively configuring the necessary components directly within your `configuration.nix`. It leverages standard NixOS options for user management and OpenSSH, allowing for precise control and integration with the rest of your system. + +* **How it works**: You define a new system user (e.g., `borg-x1carbon`) and configure its SSH `authorized_keys` entry. For security, the SSH access for this user is restricted by prepending `command="borg serve --restrict-to-path /path/to/repo"` to the public key entry. This ensures that when a client connects as this user via SSH, it can *only* execute the `borg serve` command and *only* within the specified repository path. + +#### Pros +- **Highly Secure**: Directly implements Borg's recommended security model (restricted SSH command). +- **Idiomatic & Declarative**: Managed entirely within your NixOS configuration using standard options. +- **Flexible**: Allows fine-grained control over user permissions and repository paths. +- **Integrated**: Works seamlessly with other NixOS components like `users.users` and `services.openssh`. + +#### Cons +- Requires manual definition of each user and their SSH keys. + +--- + + +### Method 2: Podman Container + +This approach involves running a community-provided Docker image (like `borgmatic/borgserver` or `nold360/borgserver`) as a Podman container on `richmond-server`. + +* **How it works**: You would define a `virtualisation.oci-containers.containers.` block. This would involve: + 1. Pulling a suitable Borg server image from Docker Hub. + 2. Mapping a host directory (e.g., `/var/lib/borg-backups`) into the container as a volume to persist the backup data. + 3. Mapping a host directory containing the `authorized_keys` file into the container's SSH directory. + 4. Publishing the container's SSH port (e.g., 2222) to a port on the host. + +#### Pros +- **Encapsulated**: The Borg environment and its dependencies are isolated from the host system. +- **Consistent Workflow**: Aligns with the existing use of Podman containers on `richmond-server`. + +#### Cons +- **Increased Complexity**: Managing persistent storage and SSH keys via volumes is more complex and prone to misconfiguration. +- **Manual Security**: You are responsible for ensuring the container image is secure and that the SSH key restrictions are correctly implemented inside the container. +- **Less Integrated**: Does not tie into the host's user or firewall management as cleanly as the native NixOS configuration. + +--- + + +## 2. Recommendation + +**The Manual NixOS Configuration is the best method.** + +It is more secure, simpler to manage, and more robust than a container-based solution for this specific use case on a NixOS system. It perfectly embodies the declarative and security-focused principles of both NixOS and Borg. + +## 3. Example Configuration for `richmond-server` + +Here is a proposed configuration snippet that you would add to `hosts/richmond-server/configuration.nix`. This example sets up a repository for a hypothetical client named `x1carbon-laptop`. + +```nix +{ + config, pkgs, lib, ... +}: + +{ + # ... existing configuration ... + + # Borg Backup Server Configuration + # Create a dedicated system user for Borg backups + users.users.borg-x1carbon = { + isSystemUser = true; + group = "borg-x1carbon"; + home = "/var/lib/borgbackup/x1carbon-main-backup"; # Home directory for this repo + createHome = true; + }; + users.groups.borg-x1carbon = {}; + + # Configure OpenSSH to allow access for the borg user with restricted commands + services.openssh.enable = true; # Ensure OpenSSH is enabled + services.openssh.authorizedKeys.keys = { + "borg-x1carbon" = [ + # IMPORTANT: Replace this with the actual public SSH key from your x1carbon laptop. + # The 'command' option restricts this key to only execute Borg serve commands. + "command=\"/run/current-system/sw/bin/borg serve --restrict-to-path /var/lib/borgbackup/x1carbon-main-backup\",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICyour_clients_public_key_here user@x1carbon" + ]; + }; + + # Ensure the base directory for backups exists and has correct permissions + systemd.tmpfiles.rules = [ + "d /var/lib/borgbackup 0700 root root -" + ]; + + # Borg uses SSH, so ensure the SSH port is open in your firewall. + # This is likely already enabled on your server. + networking.firewall.allowedTCPPorts = [ 22 ]; + + # ... rest of your configuration ... +} +``` + +## 4. Helpful Links + +- [**BorgBackup Official Documentation - Usage with SSH**](https://borgbackup.readthedocs.io/en/stable/usage/ssh.html) +- [**NixOS Wiki on BorgBackup**](https://nixos.wiki/wiki/BorgBackup) +- [**NixOS `users.users` Options**](https://search.nixos.org/options?channel=unstable&show=users.users&from=0&size=50&sort=relevance&type=packages&query=users.users) +- [**NixOS `services.openssh` Options**](https://search.nixos.org/options?channel=unstable&show=services.openssh&from=0&size=50&sort=relevance&type=packages&query=services.openssh) \ No newline at end of file diff --git a/docs/homepage-dashboard.md b/docs/homepage-dashboard.md new file mode 100644 index 0000000..45cdaf7 --- /dev/null +++ b/docs/homepage-dashboard.md @@ -0,0 +1,275 @@ +# Homepage Dashboard — Adding Machines, Tabs & Services + +This guide covers how to add new machines, tabs, and services to the **Homepage** +dashboards on **homeserver-1** (and, by extension, any host that imports the +Homepage module). + +## Overview + +Homepage (https://gethomepage.dev) is a self-hosted dashboard. All dashboard +configuration lives in Nix and is rendered to YAML files that Homepage reads +at `/etc/homepage-dashboard/`. + +| Item | Location | +|------|----------| +| Homepage NixOS module (service wrapper) | `modules/services/homepage.nix` | +| Dashboard definition for homeserver-1 | `hosts/homeserver-1/homepage.nix` | +| Deployed config files | `/etc/homepage-dashboard/{settings,services,widgets}.yaml` | +| API keys / secrets | `secrets.yaml` (SOPS) → `HOMEPAGE_VAR_*` env vars | + +The dashboard definition is split from the host config so it's easy to edit: + +- `hosts/homeserver-1/configuration.nix` enables Glances + firewall and imports: + ```nix + imports = [ + # ... + ./homepage.nix + ]; + ``` + +## Applying Changes + +After editing `homepage.nix`: + +```bash +nixos-rebuild switch --target-host petere@homeserver-1 --flake .#homeserver-1 --use-remote-sudo +``` + +> **Important:** Homepage reads its config files when the service starts. After +> a `nixos-rebuild`, the unit's definition usually changes, but if only the +> *contents* of the config files changed (e.g. just editing `homepage.nix`), the +> service may keep the old config in memory. If your changes don't appear, restart it: +> +> ```bash +> ssh petere@homeserver-1 "sudo systemctl restart homepage-dashboard" +> ``` + +## Structure of the Dashboard Definition + +`hosts/homeserver-1/homepage.nix` has three main parts: + +```nix +services.homepage = { + enable = true; + port = 8082; + allowedHosts = [ ... ]; # Host header values Homepage responds to + environmentFiles = [ ... ]; # SOPS-secret env file with HOMEPAGE_VAR_* keys + + settings = { + title = "HomeServer"; + # ... + layout = { ... }; # Controls grouping, tabs, and column widths + }; + + services = [ ... ]; # The service groups and tiles (services.yaml) + widgets = [ ... ]; # Header info widgets (resources, search, clock) +}; +``` + +## Tabs + +Tabs are enabled by adding a `tab` field to a group's **layout** entry. + +- Groups with the **same** `tab` value appear on that tab. +- Groups with **no** `tab` appear on **every** tab. +- Tabs are sorted by their order in the `layout` block. +- Each tab is deep-linkable: `#monitoring`, `#homeserver-1`, etc. + +Current tabs: + +``` +Monitoring (default tab) +├── Homeserver-1 Monitoring → 5 Glances tiles (localhost) +└── MCF Server Monitoring → 5 Glances tiles (mcf-server via tailnet) + +Homeserver-1 (#homeserver-1) +├── Media → Jellyfin, Immich +└── System → Backrest, Pocket ID, Tailscale + +MCF Server (reserved; uncomment the placeholder to enable) +``` + +## Adding a Service + +1. **Add the service tile** to the relevant group in the `services` list: + + ```nix + services = [ + # ... + { + System = [ + # ...existing tiles... + { + MyService = { + icon = "sh-myservice"; + href = "http://homeserver-1.gerbil-opah.ts.net:"; + description = "What it does"; + siteMonitor = "http://127.0.0.1:"; # green/red status + widget = { # optional live stats + type = "myservice"; + url = "http://127.0.0.1:"; + key = "{{HOMEPAGE_VAR_MYSERVICE_API_KEY}}"; # only if it needs a key + }; + }; + } + ]; + } + ]; + ``` + +2. **Set the layout** so the group renders where you want (columns = how many + tiles per row; groups span the full width with `style = "row"` when they're + a single top-level group): + + ```nix + layout = { + # ... + MyService = { # or add to an existing group's entry + tab = "Homeserver-1"; + style = "row"; + columns = 4; + }; + }; + ``` + +3. **API keys**: never hardcode secrets. Add the value to `secrets.yaml` under + the machine section and reference it via the environment file. The secret + `homeserver-1/homepage-env` already provides `HOMEPAGE_VAR_JELLYFIN_API_KEY`, + `HOMEPAGE_VAR_IMMICH_API_KEY`, `HOMEPAGE_VAR_TAILSCALE_API_KEY` and + `HOMEPAGE_VAR_TAILSCALE_DEVICEID`. To add another: + + ```bash + sops --set '["homeserver-1"]["homepage-env"] "HOMEPAGE_VAR_MYSERVICE_API_KEY="' secrets.yaml + ``` + + (This replaces the whole env file — include every existing `HOMEPAGE_VAR_*` + line when setting it.) + +## Adding a Tab + +Add a new group to `services` and give it a `tab` in the layout: + +```nix +# services +{ + "My New Group" = [ + { "MyService" = { href = "..."; }; } + ]; +} + +# layout +"My New Group" = { + tab = "My Tab"; + style = "row"; + columns = 4; +}; +``` + +## Adding a New Machine to the Dashboard + +To monitor another machine (e.g. `richmond-server`): + +### 1. Enable Glances on the target machine + +Add to that host's `configuration.nix` (Glances exposes system stats to the +dashboard): + +```nix +services.glances = { + enable = true; + port = 61208; + extraArgs = [ "--webserver" ]; +}; + +# Expose on Tailscale only +networking.firewall.interfaces.tailscale.allowedTCPPorts = [ 61208 ]; +``` + +### 2. Add the monitoring group + +In `hosts/homeserver-1/homepage.nix`, add a group with Glances tiles. **The +group name must be unique** — Homepage's widget proxy resolves widget config by +leaf group name, so duplicate group names cause one machine's stats to display +on another's tiles. + +```nix +# services +{ + "Richmond Server Monitoring" = [ + { "System" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "info"; }; }; } + { "CPU" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "cpu"; }; }; } + { "Memory" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "memory"; }; }; } + { "Disk" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "fs:/"; }; }; } + { "Processes" = { widget = { type = "glances"; url = "http://richmond-server.gerbil-opah.ts.net:61208"; version = 4; metric = "process"; }; }; } + ]; +} + +# layout +"Richmond Server Monitoring" = { + tab = "Monitoring"; + style = "row"; + columns = 5; +}; +``` + +### 3. Add non-monitoring services for that machine + +Add a group and give it its own tab (or reuse an existing one): + +```nix +# services +{ + "Richmond Server Apps" = [ + { "Ntfy" = { icon = "sh-ntfy"; href = "http://richmond-server.gerbil-opah.ts.net:8080"; }; } + ]; +} + +# layout +"Richmond Server Apps" = { + tab = "Richmond Server"; + style = "row"; + columns = 4; +}; +``` + +### 4. (Optional) Tailscale widget for the machine + +Each machine's Tailscale node can be shown with its own widget, using the +machine's numeric device ID (find it via the Tailscale API/admin console): + +```nix +{ + "Richmond Server" = [ + { + Tailscale = { + icon = "sh-tailscale"; + href = "https://login.tailscale.com/admin/machines"; + widget = { + type = "tailscale"; + deviceid = ""; # NOT the ...CNTRL value + key = "{{HOMEPAGE_VAR_TAILSCALE_API_KEY}}"; + }; + }; + } + ]; +} +``` + +## Troubleshooting + +| Symptom | Cause / Fix | +|---------|-------------| +| Changes not appearing | Service not restarted — run `sudo systemctl restart homepage-dashboard` | +| `t.metric is undefined` | Glances widget missing the `metric` field — every glances tile needs `metric` (e.g. `cpu`, `memory`, `fs:/`, `process`, `info`) | +| `no manageable device matching this ID found` | Tailscale widget `deviceid` is wrong — must be the **numeric** device ID, not the `...CNTRL` value | +| Two machines show the same stats | Duplicate group names — every group (especially monitoring) needs a unique name | +| `Host validation failed` | The `Host` header isn't in `allowedHosts` — add the hostname (with port) to `allowedHosts` | +| API keys showing in config | Keys must come from the SOPS env file (`HOMEPAGE_VAR_*`), never hardcoded | + +## Glances Metrics + +The Glances widget (`type = "glances"`) requires a `version` and `metric`: + +- `version = 4` for Glances v4.x (installed) +- `metric`: `info` (system summary), `cpu`, `memory`, `process`, `containers`, + `fs:/` (disk usage), `network:`, `sensor:`, `disk:`, `gpu:` diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..92f6abb --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,69 @@ +# Initial Installation + +This repository uses [nixos-anywhere](https://github.com/nix-community/nixos-anywhere) for seamless deployment to new hardware. + +## Prerequisites + +1. **SSH Access**: The target machine must be booted into a Linux environment (e.g., NixOS Installer ISO) with SSH enabled and your public key authorized. +2. **Secrets Management**: If the target host requires secrets, ensure its Age key is generated and added to `.sops.yaml` as described in the [SOPS Guide](sops-secrets.md). +3. **Disko**: Ensure the `disko-config.nix` for the host matches the target hardware's drive names (e.g., `/dev/nvme0n1` vs `/dev/sda`). + +## Deployment Command + +Run this command from the root of the repository: + +```bash +nix run github:nix-community/nixos-anywhere -- --flake .# +``` + +*Example:* `nix run github:nix-community/nixos-anywhere -- --flake .#x1carbon 192.168.1.50` + +The process will automatically partition the drive via `disko`, install the system, and reboot into the new NixOS environment. + +## Post-Installation + +After the first boot, apply the configuration locally: + +```bash +sudo nixos-rebuild switch --flake /etc/nixos/# +``` + +For ongoing management, clone this repository and use the commands in the main [README.md](../README.md). + +--- + +## Secrets: Using Age Keys with `--extra-files` + +Some hosts require SOPS secrets at build time (e.g., user passwords with `neededForUsers = true`). The private age key must be available during `nixos-rebuild`, but it should **never** be committed to Git. Use the `extra-files/` directory (in `.gitignore`) and the `--extra-files` flag to supply the key securely. + +### 1. Create the Age Key + +Generate a new age key and store it in the untracked `extra-files/` tree: + +```bash +mkdir -p extra-files/root/.config/sops/age +age-keygen -o extra-files/root/.config/sops/age/keys.txt +``` + +Extract the **public key** and add it to `.sops.yaml`, then re-encrypt the secrets file so this key can decrypt them: + +```bash +cat extra-files/root/.config/sops/age/keys.txt | age-keygen -y +# Copy the output public key into .sops.yaml under the `age` key list +sops updatekeys secrets.yaml +``` + +### 2. Deploy with `--extra-files` + +The `--extra-files` flag copies the local `extra-files/` directory into the Nix store so the age key is available at build time: + +```bash +sudo nixos-rebuild switch --flake .# --extra-files extra-files +``` + +This makes `extra-files/root/.config/sops/age/keys.txt` available at `/root/.config/sops/age/keys.txt` during evaluation, allowing SOPS to decrypt `secrets.yaml` without the key ever touching the target machine's filesystem. + +> **Note**: For `nixos-anywhere` initial deployments, supply the age key via `--extra-files` as well: +> ```bash +> nix run github:nix-community/nixos-anywhere -- --extra-files extra-files --flake .# +> ``` diff --git a/docs/software-inventory.md b/docs/software-inventory.md new file mode 100644 index 0000000..0f53e93 --- /dev/null +++ b/docs/software-inventory.md @@ -0,0 +1,95 @@ +# Software Inventory + +This table tracks which software is installed on each host and how it's installed. + +| Software | Installation Method | X1C | CX1 | X470 | MX270 | RS | HS1 | HPL | +| :------------------- | :------------------------------- | :-- | :-- | :--- | :---- | :- | :-- | :-: | +| **Zsh** | NixOS System / HM Module | x | x | x | x | x | x | x | +| **Tailscale** | NixOS System | x | x | x | x | x | x | x | +| **GNOME** | NixOS System | x | x | x | x | | | | +| **Hyprland** | NixOS System | | | | | | | x | +| **QuickShell** | NixOS System | | | | | | | x | +| **Wofi** | NixOS System | | | | | | | x | +| **Thunar** | NixOS System | | | | | | | x | +| **Power Mgmt** | NixOS System (upower, power-profiles-daemon, hypridle) | | | | | | | x | +| **vdirsyncer** | HM (cal sync) | | | | | | | x | +| **khal** | HM (cal sync) | | | | | | | x | +| **Firefox** | NixOS System / HM Module | x | x | x | x | | | x | +| **Chromium** | NixOS System | x | x | x | x | | | x | +| **Thunderbird** | NixOS System | x | x | x | x | | | x | +| **Bitwarden** | NixOS System | x | x | x | x | | | x | +| **VLC** | NixOS System | x | x | x | x | | | x | +| **Element Desktop** | NixOS System | x | x | x | x | | | x | +| **Kdenlive** | NixOS System | x | x | x | x | | | x | +| **Nextcloud Client** | NixOS System | x | x | x | x | | | x | +| **OnlyOffice** | NixOS System | x | x | x | x | | | x | +| **Gear Lever** | Flatpak | x | x | x | x | | | x | +| **Flatseal** | Flatpak | x | x | x | x | | | x | +| **Soundux** | Flatpak | x | x | | | | | | +| **OpenLP** | Flatpak Bundle | | | x | | | | | +| **FreeShow** | AppImage | x | x | x | | | | | +| **GIMP** | NixOS System | x | | | | | | | +| **Scribus** | NixOS System | x | | | | | | | +| **OpenShot** | NixOS System | x | | | | | | | +| **OBS Studio** | NixOS System (NDI / CUDA) | x | | | | | | | +| **OpenCode** | NixOS System | x | | | | | | | +| **MCP-NixOS** | HM User Package (x1carbon) | x | | | | | | | +| **LM Studio** | NixOS System | x | | | | | | | +| **Mixing Station** | NixOS System (Custom Derivation) | x | | | | | | | +| **X32-Edit** | NixOS System | x | | | | | | | +| **Vorta** | NixOS System | x | | | | | | | +| **Paperless-ngx** | NixOS System (Module) | x | | | | | | | +| **Winbox** | NixOS System | x | | x | | | | | +| **wvkbd** | HM User Package (x1carbon) | x | | | | | | | +| **matugen** | HM User Package (x1carbon) | x | | | | | | | +| **Xournal++** | NixOS System | x | x | | | | | | +| **Steam** | NixOS System | x | x | | | | | | +| **Prism Launcher** | NixOS System | | x | | | | | | +| **Rclone** | NixOS System | | x | | | | | | +| **Pi-hole** | Container (Podman) | | | | | x | | | +| **Castopod** | Container (Podman) | | | | | x | | | +| **MCFNotices** | Container (Podman) | | | | | x | | | +| **Ntfy** | NixOS System (Module) | | | | | x | | | +| **Immich** | NixOS System (Module) | | | | | | x | | +| **Pocket ID** | NixOS System (Module) | | | | | | x | | +| **Jellyfin** | NixOS System (Module) | | | | | | x | | +| **Backrest** | NixOS System (Module) | | | | | | x | | +| **Gitea** | NixOS System (Module) | | | | | | x | | +| **Homepage** | NixOS System (Module) | | | | | | x | | +| **MariaDB** | NixOS System | | | | | x | | | +| **BorgBackup** | _Removed - migrated to Backrest_ | | | | | | | | +| **NVIDIA Drivers** | NixOS System | x | | | | | x | | +| **VS Code** | HM Module | x | x | x | x | | | x | +| **Kitty** | HM Module | x | x | x | x | | | x | +| **Direnv** | HM User | x | x | x | x | x | | x | +| **Git** | HM User / System | x | x | x | x | x | x | x | +| **Fastfetch** | HM User | x | x | x | x | x | | x | +| **Python / UV** | NixOS System | x | x | | x | | | x | +| **Flutter** | NixOS System | x | x | | x | | | x | +| **Disko** | NixOS Module | x | x | x | x | x | x | x | +| **SOPS** | NixOS Module / System | x | x | x | x | x | x | x | +| **Microsoft Core Fonts** | NixOS System (Fonts) | x | x | x | x | x | x | x | +| **Admin tools** | NixOS System (`management.nix`) | x | x | x | x | x | x | x | + +## Legend + +| Code | Host | +|------|------| +| **X1C** | `x1carbon` | +| **CX1** | `caitlin-x1` | +| **X470** | `x470` | +| **MX270** | `mary-x270` | +| **RS** | `richmond-server` | +| **HS1** | `homeserver-1` | +| **HPL** | `hp-laptop` | + +## Installation Methods + +| Method | Description | +|--------|-------------| +| **NixOS System** | Package installed via `environment.systemPackages` or `services..enable` | +| **HM Module** | Home Manager module (`programs..enable`) | +| **HM User** | Home Manager user-level config (`home.packages`) | +| **Flatpak** | Installed via `services.flatpak.packages` | +| **Container (Podman)** | Runs as an OCI container via Podman | +| **AppImage** | Standalone AppImage bundle | diff --git a/docs/sops-secrets.md b/docs/sops-secrets.md new file mode 100644 index 0000000..1fab9da --- /dev/null +++ b/docs/sops-secrets.md @@ -0,0 +1,128 @@ +# SOPS Secrets Management Guide + +This guide covers how to manage encrypted secrets in Nix-Vibe using `sops-nix` with `age` encryption. + +## Overview + +- **Secrets File**: `secrets.yaml` (encrypted in Git) +- **Configuration**: `.sops.yaml` (public keys and creation rules) +- **Private Key**: Locally stored at `~/.config/sops/age/keys.txt` (Never commit this!) + +## Prerequisites + +The project includes `sops` in the default development environment. You can also run it temporarily: + +```bash +nix shell nixpkgs#sops nixpkgs#age +``` + +## Adding or Updating Secrets + +The easiest way to add a new secret (like a password or API key) is to use the `sops --set` command from the root of the repository. + +### Adding a Key-Value Pair + +```bash +sops --set '[""][""] ""' secrets.yaml +``` + +*Example (Adding a server password):* +```bash +sops --set '["richmond-server"]["new-password"] "supersecret123"' secrets.yaml +``` + +*Example (Adding a global user password):* +```bash +sops --set '["users"]["petere-password"] "mypassword"' secrets.yaml +``` + +### Editing the Secrets File Directly + +To open the entire decrypted file in your editor: + +```bash +sops secrets.yaml +``` + +## Using Secrets in Configuration + +### 1. NixOS System Secrets + +In `hosts//configuration.nix`: + +```nix +sops.secrets."machine-name/new-secret" = { + owner = "root"; + group = "root"; + mode = "0400"; +}; +``` + +**Note:** For user passwords, add `neededForUsers = true;` to ensure the secret is decrypted early enough for the account to be created. + +### 2. Home Manager Secrets + +Home Manager secrets are defined in the user's profile (e.g., `home-manager/users/petere.nix`): + +```nix +sops.secrets."gemini-api-key" = { }; +``` + +Access the decrypted path in your configuration: +`config.sops.secrets."gemini-api-key".path` + +### 3. Rendered Templates (`sops.templates`) + +For config files that embed a secret (e.g. an env file consumed by a container), +use `sops.templates` so sops-nix renders the file with correct permissions and +re-renders it at boot/switch — no shell `preStart` needed: + +```nix +sops.templates."pihole-env" = { + content = '' + FTLCONF_webserver_api_password=${config.sops.placeholder."richmond-server/pihole-password"} + ''; + path = "/run/pihole-env"; + mode = "0600"; +}; +``` + +The `${config.sops.placeholder.""}` reference is substituted with the +decrypted value at runtime. Live examples: `hosts/richmond-server/configuration.nix` +(pihole + castopod env files). + +### Multi-line Secrets + +Store SSH keys and other multi-line values as YAML **block scalars** (using `|`) +so they decrypt with real newlines. `hosts/homeserver-1/configuration.nix` +installs the `restic-ssh-key` verbatim with `install`, so that secret must be +multi-line (not a single line with escaped spaces). + +## Rotating / Adding New Machine Keys + +When deploying to a new machine, you must generate an age key and add its public key to `.sops.yaml`. + +1. **Generate the key on the target machine**: + ```bash + mkdir -p ~/.config/sops/age + age-keygen -o ~/.config/sops/age/keys.txt + ``` + +2. **Get the public key**: + ```bash + cat ~/.config/sops/age/keys.txt | age-keygen -y + ``` + +3. **Update `.sops.yaml`**: + Add the new public key to the `age` list. + +4. **Re-encrypt the secrets file**: + ```bash + sops updatekeys secrets.yaml + ``` + +## Troubleshooting + +- **"No sops config found"**: Ensure you are in the repository root. +- **Decryption Failure**: Ensure your private key is at `~/.config/sops/age/keys.txt` or set `export SOPS_AGE_KEY_FILE=...`. +- **Pure Evaluation Mode**: Nix Flakes in pure mode cannot read absolute paths (like `/run/secrets/...`). Use `sops.templates` or runtime injection instead of `preStart` (see `hosts/richmond-server/configuration.nix`). diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..415dd0b --- /dev/null +++ b/flake.lock @@ -0,0 +1,345 @@ +{ + "nodes": { + "disko": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781152676, + "narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=", + "owner": "nix-community", + "repo": "disko", + "rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "disko", + "type": "github" + } + }, + "disko_2": { + "inputs": { + "nixpkgs": [ + "nixos-anywhere", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1781152676, + "narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=", + "owner": "nix-community", + "repo": "disko", + "rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "master", + "repo": "disko", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1788651960, + "narHash": "sha256-v9wJd32eZ2bvhBzVOd7TIjLQd011P7nwOhjKtWlci5I=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "2c0350c759688177331b8f5242311fae8877bdb3", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "home-manager-stable": { + "inputs": { + "nixpkgs": [ + "nixpkgs-stable" + ] + }, + "locked": { + "lastModified": 1747688870, + "narHash": "sha256-ypL9WAZfmJr5V70jEVzqGjjQzF0uCkz+AFQF7n9NmNc=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "d5f1f641b289553927b3801580598d200a501863", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-24.11", + "repo": "home-manager", + "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" + } + }, + "nix-vm-test": { + "inputs": { + "nixpkgs": [ + "nixos-anywhere", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786747096, + "narHash": "sha256-9QqhmaLVsPhKdMSBaWKjDqeGRn8G4ov4cVuZ6JFwXbo=", + "owner": "numtide", + "repo": "nix-vm-test", + "rev": "c8781a0ea2d8417506fff7722eae5a6316461212", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "nix-vm-test", + "type": "github" + } + }, + "nixos-anywhere": { + "inputs": { + "disko": "disko_2", + "nix-vm-test": "nix-vm-test", + "nixos-images": "nixos-images", + "nixos-stable": "nixos-stable", + "nixpkgs": [ + "nixpkgs" + ], + "treefmt-nix": "treefmt-nix" + }, + "locked": { + "lastModified": 1787728766, + "narHash": "sha256-g2oZlrBU3AI2ubCiY/UyE9ALTIDueTcF//QP3vaY9IQ=", + "owner": "nix-community", + "repo": "nixos-anywhere", + "rev": "6b77f26ec4538ced04bf1d02f374b0ec02e9c27e", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixos-anywhere", + "type": "github" + } + }, + "nixos-images": { + "inputs": { + "nixos-stable": [ + "nixos-anywhere", + "nixos-stable" + ], + "nixos-unstable": [ + "nixos-anywhere", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1787222173, + "narHash": "sha256-acp6QJnWVnLvnanC59CMkiDC/i0ZhdFKiB7prru9SHw=", + "owner": "nix-community", + "repo": "nixos-images", + "rev": "e17386d9193d6d5a90f1b4b6a8a5cd2620d34b56", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixos-images", + "type": "github" + } + }, + "nixos-stable": { + "locked": { + "lastModified": 1787204541, + "narHash": "sha256-OURZPknrTjQrlNyxPdqzyqmU/81Wes1CUP/Ft1Rv/YI=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5880666fd9eb563038431edb35c2d0aa595884e6", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1788752844, + "narHash": "sha256-VaWGJ6+cIYN2erfSecbRV+4ljI185Ty2wUrXyvQbgOw=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "dc5d91f840324650bac8c379428c7037a416959a", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1789684949, + "narHash": "sha256-ZKhUe/2IJUq1JhKxKMu8rbkgSGmPP2ZCqlIPn40aGCM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e554fab72f81915600f3f449b786fd9af40439a5", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "disko": "disko", + "home-manager": "home-manager", + "home-manager-stable": "home-manager-stable", + "nix-flatpak": "nix-flatpak", + "nixos-anywhere": "nixos-anywhere", + "nixpkgs": "nixpkgs", + "nixpkgs-stable": "nixpkgs-stable", + "sops-nix": "sops-nix", + "teleportfling": "teleportfling" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1788337237, + "narHash": "sha256-gkSH8VUtCo6hnysNmb9DbTuDepH2t5pv+QWjP75xKAk=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "fbf759290e0cb0a98dfc813a4eb7d53ad1dacb57", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "sops-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" + } + }, + "teleportfling": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1789810501, + "narHash": "sha256-OAdYGZP2lcN5eRmy/liflctV7gSWObGJBD2w9QQgN8E=", + "ref": "refs/heads/main", + "rev": "921e6d65dfc6267f339a30474a5b37072a99c474", + "revCount": 20, + "type": "git", + "url": "http://homeserver:3050/pedley/TeleportFling.git" + }, + "original": { + "type": "git", + "url": "http://homeserver:3050/pedley/TeleportFling.git" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixos-anywhere", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1786901030, + "narHash": "sha256-WSFCsDSE5ffgD2MqzkM2CYjeFiKhRF/dJUN8uedb6YE=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "27b3b12a8e6375f28ebe122f07d230ca5459bbfa", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..863ee0b --- /dev/null +++ b/flake.nix @@ -0,0 +1,216 @@ +{ + description = "A vibrant NixOS configuration for multiple machines"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-24.11"; + + home-manager = { + url = "github:nix-community/home-manager"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + home-manager-stable = { + url = "github:nix-community/home-manager/release-24.11"; + inputs.nixpkgs.follows = "nixpkgs-stable"; + }; + + disko = { + url = "github:nix-community/disko"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + nixos-anywhere = { + url = "github:nix-community/nixos-anywhere"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + sops-nix = { + url = "github:Mic92/sops-nix"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + + nix-flatpak.url = "github:gmodena/nix-flatpak"; + + # TeleportFling: standalone screen + audio sender for OBS Teleport. + teleportfling.url = "git+http://homeserver:3050/pedley/TeleportFling.git"; + }; + + outputs = + { self + , nixpkgs + , nixpkgs-stable + , home-manager + , home-manager-stable + , disko + , nixos-anywhere + , sops-nix + , nix-flatpak + , teleportfling + , ... + }@inputs: + let + system = "x86_64-linux"; + serverOverlays = [ + # Empty - antigravity removed + ]; + # Overlays shared by all desktops/laptops (no CUDA/NDI) + desktopOverlays = [ + (final: prev: { + stable = import nixpkgs-stable { + system = final.stdenv.hostPlatform.system; + config = { + allowUnfree = true; + allowBroken = true; + }; + }; + }) + ]; + # Full overlay set used by x1carbon only (CUDA + NDI for OBS Studio etc.) + overlays = [ + (final: prev: { + stable = import nixpkgs-stable { + system = final.stdenv.hostPlatform.system; + config = { + allowUnfree = true; + allowBroken = true; + cudaSupport = true; + cudaCapabilities = [ "6.1" ]; + }; + }; + }) + (final: prev: { + ndi-6 = prev.ndi-6.overrideAttrs (old: { + src = prev.fetchurl { + url = "https://downloads.ndi.tv/SDK/NDI_SDK_Linux/Install_NDI_SDK_v6_Linux.tar.gz"; + hash = "sha256-8DFPJFRG3vxIi2POtGiazxqWWu79ray3BXG7IWqMwYM="; + }; + }); + }) + ]; + # Packages with known vulnerabilities that are explicitly permitted. + # Single-sourced here; referenced by the mkNixosSystem module below. + permittedInsecurePackages = [ + "electron-39.8.10" + "qtwebengine-5.15.19" + "ventoy-1.1.17" + ]; + + # Specific pkgs for Desktop with CUDA support (using stable to avoid broken CUDA builds) + # No longer needed as a separate input, we use overlays now. + # pkgsDesktop = ... (Removed) + + # Function to generate a NixOS system configuration + hostUsers = { + x1carbon = [ "petere" ]; + caitlin-x1 = [ + "caitlin" + "petere" + ]; + x470 = [ + "petere" + "guest" + ]; + mary-x270 = [ + "mary" + "petere" + ]; + richmond-server = [ "petere" ]; + homeserver-1 = [ "petere" ]; + mcf-server = [ "petere" ]; + mcf-stream = [ + "guest" + "petere" + ]; + hp-laptop = [ "petere" ]; + }; + + # Function to generate a NixOS system configuration + mkNixosSystem = + hostname: + { specialArgs ? { } + , modules ? [ ] + , hostOverlays ? desktopOverlays + , nixpkgsInput ? nixpkgs + , homeManagerInput ? home-manager + , + }: + nixpkgsInput.lib.nixosSystem { + # Use the original nixpkgs.lib here + specialArgs = { + inherit inputs; + } + // specialArgs; # Removed pkgs from specialArgs + modules = [ + { + nixpkgs.config.allowUnfree = true; + nixpkgs.config.permittedInsecurePackages = permittedInsecurePackages; + nixpkgs.overlays = hostOverlays; + } + sops-nix.nixosModules.sops + (./hosts + "/${hostname}/configuration.nix") + homeManagerInput.nixosModules.home-manager + nix-flatpak.nixosModules.nix-flatpak + { + home-manager.useGlobalPkgs = true; + home-manager.useUserPackages = true; + home-manager.backupFileExtension = "backup"; + home-manager.extraSpecialArgs = { inherit inputs; }; + home-manager.sharedModules = [ + ./home-manager/modules/zsh.nix + ]; + } + ] + ++ (nixpkgs.lib.map # Use original nixpkgs.lib here + (user: import (./home-manager/users + "/${user}.nix")) + (hostUsers.${hostname} or [ ]) + ) + ++ modules; + }; + in + { + nixosConfigurations = { + x1carbon = mkNixosSystem "x1carbon" { + hostOverlays = overlays; + }; + + caitlin-x1 = mkNixosSystem "caitlin-x1" { }; + + x470 = mkNixosSystem "x470" { }; + + mary-x270 = mkNixosSystem "mary-x270" { }; + + richmond-server = mkNixosSystem "richmond-server" { + hostOverlays = serverOverlays; + }; + + homeserver-1 = mkNixosSystem "homeserver-1" { + hostOverlays = serverOverlays; + }; + + mcf-server = mkNixosSystem "mcf-server" { + hostOverlays = serverOverlays; + }; + + mcf-stream = mkNixosSystem "mcf-stream" { }; + + hp-laptop = mkNixosSystem "hp-laptop" { }; + }; + + packages.${system} = { }; + + formatter.${system} = nixpkgs.legacyPackages.${system}.nixfmt; + + devShells.${system}.default = + let + devPkgs = nixpkgs.legacyPackages.${system}; + in + devPkgs.mkShell { + packages = with devPkgs; [ + sops + age + nixfmt + ]; + }; + }; +} diff --git a/home-manager/modules/desktop-user.nix b/home-manager/modules/desktop-user.nix new file mode 100644 index 0000000..6ec7e83 --- /dev/null +++ b/home-manager/modules/desktop-user.nix @@ -0,0 +1,51 @@ +{ pkgs, lib, ... }: + +{ + imports = [ + ./gnome.nix + ./kitty.nix + ./firefox.nix + ./tools/just.nix + ]; + + home.activation.linkOnlyOfficeFonts = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + FONT_DIR="/run/current-system/sw/share/X11/fonts" + USER_FONTS="$HOME/.local/share/fonts" + NATIVE_TARGET="$HOME/.local/share/onlyoffice/desktopeditors/data/fonts" + FLATPAK_TARGET="$HOME/.var/app/org.onlyoffice.desktopeditors/data/onlyoffice/desktopeditors/data/fonts" + + if [ -d "$FONT_DIR" ]; then + for TARGET_DIR in "$USER_FONTS" "$NATIVE_TARGET" "$FLATPAK_TARGET"; do + $DRY_RUN_CMD mkdir -p "$TARGET_DIR" + $DRY_RUN_CMD rm -f "$TARGET_DIR/AllFonts.js"* "$TARGET_DIR/fonts_thumbnail"* "$TARGET_DIR/font_selection.bin" "$TARGET_DIR/fonts.log" + $DRY_RUN_CMD cp -Lf "$FONT_DIR"/*.ttf "$FONT_DIR"/*.otf "$FONT_DIR"/*.ttc "$TARGET_DIR/" 2>/dev/null || true + done + fi + ''; + + programs.vscode = { + enable = true; + package = pkgs.vscode; + profiles.default = { + extensions = + with pkgs.vscode-extensions; + [ + bbenoist.nix + ms-python.python + dart-code.flutter + ] + ++ [ + # OpenCode Go: Copilot Provider - pinned to specific version for reproducibility. + # This extension is not in nixpkgs, only available from VSCode Marketplace. + (pkgs.vscode-utils.extensionFromVscodeMarketplace { + name = "opencode-go-for-copilot"; + publisher = "DenizhanDaklr"; + version = "0.1.18"; + sha256 = "0igdkgv6dra3zpd80lhwymdh216dqn8m8kqwsxh258wyk9fzgfk0"; + }) + ]; + }; + }; + + fonts.fontconfig.enable = true; +} diff --git a/home-manager/modules/fastfetch.json b/home-manager/modules/fastfetch.json new file mode 100644 index 0000000..e9b30d7 --- /dev/null +++ b/home-manager/modules/fastfetch.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json", + "modules": [ + "title", + "separator", + "os", + "host", + "kernel", + "uptime", + "packages", + "shell", + "display", + "de", + "wm", + "wmtheme", + "theme", + "icons", + "font", + "cursor", + "terminal", + "terminalfont", + "cpu", + "gpu", + "memory", + "swap", + "disk", + "localip", + "battery", + "poweradapter", + "locale", + "break", + { + "type": "command", + "key": "Age Public Key", + "text": "cat /tmp/age-public-key.txt 2>/dev/null || echo 'Not generated yet'" + }, + "colors" + ] +} diff --git a/home-manager/modules/firefox.nix b/home-manager/modules/firefox.nix new file mode 100644 index 0000000..00cb6d1 --- /dev/null +++ b/home-manager/modules/firefox.nix @@ -0,0 +1,19 @@ +{ pkgs, ... }: + +{ + programs.firefox = { + enable = true; + configPath = ".mozilla/firefox"; + profiles.petere = { + isDefault = true; + settings = { + "ui.systemUsesDarkTheme" = 1; + "browser.in-content.dark-mode" = true; + "extensions.activeThemeID" = "firefox-compact-dark@mozilla.org"; + "browser.newtabpage.enabled" = false; + "browser.startup.homepage" = "about:blank"; + "browser.aboutConfig.showWarning" = false; + }; + }; + }; +} diff --git a/home-manager/modules/gnome-extensions.nix b/home-manager/modules/gnome-extensions.nix new file mode 100644 index 0000000..8c5a56e --- /dev/null +++ b/home-manager/modules/gnome-extensions.nix @@ -0,0 +1,20 @@ +{ pkgs, ... }: + +# Shared GNOME extensions: AppIndicator, User Themes, Caffeine +{ + dconf.settings = { + "org/gnome/shell" = { + enabled-extensions = [ + "user-theme@gnome-shell-extensions.gcampax.github.com" + "appindicatorsupport@rgcjonas.gmail.com" + "caffeine@patapon.info" + ]; + }; + }; + + home.packages = with pkgs; [ + gnomeExtensions.appindicator + gnomeExtensions.user-themes + gnomeExtensions.caffeine + ]; +} diff --git a/home-manager/modules/gnome.nix b/home-manager/modules/gnome.nix new file mode 100644 index 0000000..b06a53d --- /dev/null +++ b/home-manager/modules/gnome.nix @@ -0,0 +1,169 @@ +{ config +, pkgs +, lib +, ... +}: + +{ + dconf.settings = { + # Desktop interface settings + "org/gnome/desktop/interface" = { + color-scheme = "prefer-dark"; + enable-hot-corners = false; + clock-show-weekday = true; + show-battery-percentage = true; + + icon-theme = "Papirus-Dark"; + cursor-theme = "Bibata-Modern-Classic"; + }; + + "org/gnome/desktop/input-sources" = { + sources = [ + (lib.gvariant.mkTuple [ + "xkb" + "gb" + ]) + ]; + xkb-options = [ "terminate:ctrl_alt_bksp" ]; + }; + + # Window manager preferences + "org/gnome/desktop/wm/preferences" = { + button-layout = "appmenu:minimize,maximize,close"; + num-workspaces = 4; + }; + + # Keybindings + "org/gnome/desktop/wm/keybindings" = { + close = [ "q" ]; + switch-to-workspace-1 = [ "1" ]; + switch-to-workspace-2 = [ "2" ]; + switch-to-workspace-3 = [ "3" ]; + switch-to-workspace-4 = [ "4" ]; + move-to-workspace-1 = [ "1" ]; + move-to-workspace-2 = [ "2" ]; + move-to-workspace-3 = [ "3" ]; + move-to-workspace-4 = [ "4" ]; + }; + + # Shell settings + "org/gnome/shell" = { + favorite-apps = [ + "firefox.desktop" + "kitty.desktop" + "org.gnome.Nautilus.desktop" + "thunderbird.desktop" + "chromium-browser.desktop" + ]; + enabled-extensions = [ + "user-theme@gnome-shell-extensions.gcampax.github.com" + "appindicatorsupport@rgcjonas.gmail.com" + "caffeine@patapon.info" + "screen-rotate@shyzus.github.io" + "gsconnect@andyholmes.github.io" + ]; + }; + + # Mutter (window manager) settings + "org/gnome/mutter" = { + dynamic-workspaces = false; + edge-tiling = true; + workspaces-only-on-primary = true; + }; + + # File manager (Nautilus) settings + "org/gnome/nautilus/preferences" = { + default-folder-viewer = "list-view"; + search-filter-time-type = "last_modified"; + show-hidden-files = true; + }; + + # Power settings + "org/gnome/settings-daemon/plugins/power" = { + sleep-inactive-ac-type = "nothing"; + sleep-inactive-battery-timeout = 1800; + }; + + # Privacy settings + "org/gnome/desktop/privacy" = { + remember-recent-files = true; + remove-old-temp-files = true; + remove-old-trash-files = true; + }; + + # Session settings + "org/gnome/desktop/session" = { + idle-delay = lib.gvariant.mkUint32 900; # 15 minutes + }; + + # Screensaver settings + "org/gnome/desktop/screensaver" = { + lock-enabled = true; + lock-delay = lib.gvariant.mkUint32 0; + }; + + # Caffeine extension settings + "org/gnome/shell/extensions/caffeine" = { + enable-fullscreen = true; + restore-state = true; + show-indicator = "always"; + show-notifications = false; + }; + }; + + # GNOME-specific packages + home.packages = with pkgs; [ + gnome-tweaks + gnomeExtensions.appindicator + gnomeExtensions.user-themes + gnomeExtensions.caffeine + gnomeExtensions.gsconnect + dconf-editor + catppuccin-gtk + papirus-icon-theme + bibata-cursors + ]; + + # GTK theme configuration + gtk = { + enable = true; + gtk4.theme = config.gtk.theme; + theme = { + name = "catppuccin-frappe-blue-standard"; + package = pkgs.catppuccin-gtk; + }; + iconTheme = { + name = "Papirus-Dark"; + package = pkgs.papirus-icon-theme; + }; + cursorTheme = { + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; + }; + }; + + # Home Manager cursor configuration + home.pointerCursor = { + enable = true; + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; + gtk.enable = true; + x11.enable = true; + }; + + home.file."Pictures/wallpapers/tokyo-night.png".source = ../../assets/wallpapers/tokyo-night.png; + + # Set initial default wallpaper on first install if user hasn't configured a custom wallpaper yet. + # If the user changes their wallpaper via GNOME Settings, dconf read detects it and leaves it untouched across reboots/updates. + home.activation.setInitialWallpaper = lib.hm.dag.entryAfter [ "dconf" ] '' + WALLPAPER="${config.home.homeDirectory}/Pictures/wallpapers/tokyo-night.png" + VAL="$(${pkgs.dconf}/bin/dconf read /org/gnome/desktop/background/picture-uri 2>/dev/null || true)" + if [ -z "$VAL" ]; then + $DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri "'file://$WALLPAPER'" + $DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-uri-dark "'file://$WALLPAPER'" + $DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/background/picture-options "'zoom'" + $DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/screensaver/picture-uri "'file://$WALLPAPER'" + $DRY_RUN_CMD ${pkgs.dconf}/bin/dconf write /org/gnome/desktop/screensaver/picture-options "'zoom'" + fi + ''; +} diff --git a/home-manager/modules/hyprland-tablet-daemon.py b/home-manager/modules/hyprland-tablet-daemon.py new file mode 100644 index 0000000..54cf7cd --- /dev/null +++ b/home-manager/modules/hyprland-tablet-daemon.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +# hyprland-tablet-daemon.py — Hyprland tablet-mode rotation + on-screen keyboard. +# +# Wired to the ThinkPad X1 Yoga Gen 6 (Intel HID switches, /dev/input/event17): +# * SW_TABLET_MODE tells us the LCD is folded into tablet mode. +# * "Accelerometer orientation changed: " lines are parsed from +# `monitor-sensor` (a thin client of iio-sensor-proxy, which runs as a +# system service on this host). iio-sensor-proxy merges the accel axes + +# mount matrix into a single compass-style orientation string. +# +# While in tablet mode the eDP-1 monitor transform (and matching per-device +# transforms for touch inputs) follow the accelerometer. The on-screen +# keyboard (wvkbd) follows text focus instead of Hyprland's input-method v2 +# protocol (which Hyprland advertises but never fires): +# * We watch `.socket2.sock` "activewindow>>class,title" events. If the +# focused window class is in the text-capable allowlist the keyboard is +# shown (SIGUSR2), otherwise hidden (SIGUSR1). This gives reliable +# auto-hide when there is nothing to type into. +# * Manual control (super+crtl+k / the bar chip) flips wvkbd directly via +# SIGRTMIN. The daemon re-applies focus state on the next focus change, +# so a manual dismissal lasts until the user switches windows. +# Leaving tablet mode snaps transform back to 0 and kills wvkbd. +# +# State is mirrored to a small JSON file ($XDG_RUNTIME_DIR/hyprland-tablet) +# that the QuickShell bar chip reads via `hyprland-tablet status`: +# {"tablet": bool, "osk": visible-or-not} +# +# Control commands arrive over a Unix datagram socket +# ($XDG_RUNTIME_DIR/hyprland-tablet.ctl): "toggle" | "show" | "hide". + +import json +import os +import re +import signal +import socket +import struct +import subprocess +import sys +import threading +import time + +# Orientation string (from iio-sensor-proxy) -> Hyprland monitor transform. +# Hyprland transform: 0 normal, 1 90° CCW, 2 180°, 3 270° CCW. +# If the screen rotates the wrong way on hardware, swap the 1 and 3 below. +ORIENTATION_TRANSFORM = { + "normal": 0, + "left-up": 1, + "bottom-up": 2, + "right-up": 3, +} + +SW_TABLET_MODE = 0x01 # input event code for SW_TABLET_MODE + +# Paths resolved at build time by the Nix module (wrapped with pkgs.python3). +# The placeholder strings below are in at-sign-delimited form because +# pkgs.replaceVarsWith substitutes exactly that syntax. +EVDEV_DEVICE = "@EVDEV_DEVICE@" +WVKBD_PATH = "@WVKBD_PATH@" +WVKBD_ARGS = @WVKBD_ARGS@ # replaced with a JSON array (valid Python list) +MONITOR_SENSOR = "@MONITOR_SENSOR@" +HYPRCTL = "@HYPRCTL@" +TEXT_APPS = @TEXT_APPS_JSON@ # replaced with a JSON array (valid Python list) +# State file lives in $XDG_RUNTIME_DIR (set by the user systemd service); the +# QuickShell bar chip + CLI read the same path. +STATE_FILE = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet") +CONTROL_SOCKET = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "hyprland-tablet.ctl") + +DEBUG = os.environ.get("HYPRLAND_TABLET_DEBUG") == "1" + + +def log(msg: str) -> None: + if DEBUG: + print(f"[hyprland-tablet] {msg}", file=sys.stderr, flush=True) + + +_state_lock = threading.Lock() + + +def write_state(tablet: bool, osk: bool) -> None: + try: + with open(STATE_FILE, "w") as f: + json.dump({"tablet": tablet, "osk": osk}, f) + except OSError: + pass + + +def hyprctl_transform(monitor: str, transform: int) -> None: + """Apply a transform to the monitor and every touch/tablet device.""" + # hyprland.nix uses the Lua config parser (0.55+), so the legacy + # `monitor NAME,transform,N` / input keyword lines don't apply at runtime + # ("keyword can't work with non-legacy parsers"). The equivalent Lua API: + # hl.monitor({ output = "NAME", mode = ..., scale = ..., transform = N }) + # hl.config({ input = { touchdevice/tablet = { transform = N } } }) + # scale MUST be passed explicitly: omitting it makes Hyprland re-derive + # HiDPI zoom (1.5 here), blowing up the bar on rotation. + tx = [HYPRCTL, "eval", f'hl.monitor({{ output = "{monitor}", mode = "preferred", position = "auto", scale = 1, transform = {transform} }})'] + subprocess.run(tx, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + # touchdevice transform follows the display so touch coordinates track the + # rotated framebuffer. input:touchdevice:transform is a global input option + # (not per-device device:touchdevice:transform which doesn't exist). + subprocess.run( + [HYPRCTL, "eval", f'hl.config({{ input = {{ touchdevice = {{ transform = {transform} }} }} }})'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + # same for the tablet (pen) — this Yoga presents the Wacom digitizer as a + # tablet device, and without this the pen coordinates stay in screen space. + subprocess.run( + [HYPRCTL, "eval", f'hl.config({{ input = {{ tablet = {{ transform = {transform} }} }} }})'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + + +def transform_for(orientation: str) -> int: + return ORIENTATION_TRANSFORM.get(orientation, 0) + + +def _hypr_socket(name: str) -> str: + """Locate a Hyprland IPC socket (".socket.sock" / ".socket2.sock").""" + runtime = os.environ.get("XDG_RUNTIME_DIR", "/tmp") + inst = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE") + if inst: + candidate = os.path.join(runtime, "hypr", inst, name) + if os.path.exists(candidate): + return candidate + base = os.path.join(runtime, "hypr") + try: + for entry in os.listdir(base): + candidate = os.path.join(base, entry, name) + if os.path.exists(candidate): + return candidate + except OSError: + pass + return "" + + +class Daemon: + def __init__(self, monitor: str) -> None: + self.monitor = monitor + self.tablet = False + self.orientation = "normal" + self.current_transform = 0 + self.osk_proc = None + self.osk_visible = False + self.dev = None + self.sensor = None + self.last_focus_class = "" + + # ------------------------------------------------------------- + # evdev switch watcher (thread) + # ------------------------------------------------------------- + def open_evdev(self) -> bool: + try: + import evdev + import evdev.ecodes as ecodes + + self.dev = evdev.InputDevice(EVDEV_DEVICE) + except Exception as e: + log(f"cannot open {EVDEV_DEVICE}: {e}") + return False + # Seed the current switch state via EVIOCGSW so the daemon is correct + # if it starts mid-tablet. + EVIOCGSW = (2 << 30) | (ord("E") << 8) | 0x1B | (64 << 16) + buf = bytearray(64) + try: + import fcntl + + fcntl.ioctl(self.dev.fd, EVIOCGSW, buf) + vals = struct.unpack("16i", buf) + self.tablet = bool(vals[0] >> SW_TABLET_MODE & 1) + except OSError: + self.tablet = False + log(f"opened {self.dev.name}, starting tablet={self.tablet}") + return True + + def evdev_thread(self) -> None: + import evdev.ecodes as ecodes + + for event in self.dev.read_loop(): + if event.type != ecodes.EV_SW or event.code != SW_TABLET_MODE: + continue + new = event.value == 1 + if new != self.tablet: + self.tablet = new + log(f"SW_TABLET_MODE -> {new}") + self.on_tablet_changed(new) + + # ------------------------------------------------------------- + # orientation watcher (thread) + # ------------------------------------------------------------- + def _start_sensor(self) -> bool: + try: + self.sensor = subprocess.Popen( + [MONITOR_SENSOR], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + bufsize=1, + ) + except FileNotFoundError: + log("monitor-sensor not found") + return False + return self.sensor.stdout is not None + + def sensor_thread(self) -> None: + # monitor-sensor (a thin client of iio-sensor-proxy) only emits + # "Accelerometer orientation changed:" on *changes*; the initial + # orientation arrives as "Has accelerometer (orientation: X, ...)". + # Match both, and restart the client if it ever exits so orientation + # tracking survives. + first = True + while True: + if not self._start_sensor(): + return + if self.sensor is None or self.sensor.stdout is None: + return + if first: + log("monitor-sensor started") + first = False + for line in self.sensor.stdout: + m = re.search(r"Accelerometer orientation changed:\s*(\S+)", line.strip()) + if m: + self.on_orientation(m.group(1)) + continue + m = re.search(r"Has accelerometer \(orientation:\s*(\S+)", line.strip()) + if m: + # value is "normal," / "left-up," — strip the separator + self.on_orientation(m.group(1).rstrip(",")) + # monitor-sensor exited: reopen it (iio-sensor-proxy may have + # dropped our client if a second one connected). + log("monitor-sensor exited, restarting") + time.sleep(1) + + # ------------------------------------------------------------- + # Hyprland focus watcher (thread) — drives auto show/hide + # ------------------------------------------------------------- + def focus_thread(self) -> None: + path = _hypr_socket(".socket2.sock") + if not path: + log("hyprland .socket2.sock not found; focus auto-hide disabled") + return + log(f"watching focus events on {path}") + while True: + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.connect(path) + f = s.makefile("r") + for line in f: + line = line.strip() + if not line.startswith("activewindow>>"): + continue + payload = line.split(">>", 1)[1].split(",", 1) + self.on_focus_changed(payload[0].strip()) + except OSError as e: + log(f"focus socket error: {e}") + time.sleep(2) + + def _is_text_app(self, window_class: str) -> bool: + if not window_class: + return False + lowered = window_class.lower() + for spec in TEXT_APPS: + if lowered == spec or lowered.startswith(spec): + return True + return False + + def on_focus_changed(self, window_class: str) -> None: + self.last_focus_class = window_class + if not self.tablet: + return # in laptop mode the OSK duty is entirely manual + want = self._is_text_app(window_class) + log(f"focus={window_class!r} text_app={want}") + self.set_osk_visible(want) + + # ------------------------------------------------------------- + # control socket (thread) — manual show/hide/toggle + # ------------------------------------------------------------- + def control_thread(self) -> None: + try: + os.unlink(CONTROL_SOCKET) + except OSError: + pass + try: + s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + s.bind(CONTROL_SOCKET) + s.settimeout(1.0) + except OSError as e: + log(f"cannot bind control socket: {e}") + return + log(f"control socket ready at {CONTROL_SOCKET}") + while True: + try: + data, _ = s.recvfrom(128) + except socket.timeout: + continue + except OSError: + break + cmd = data.decode("ascii", "replace").strip() + log(f"control command: {cmd}") + if cmd == "toggle": + self.toggle_osk() + elif cmd == "show": + self.set_osk_visible(True) + elif cmd == "hide": + self.set_osk_visible(False) + + # ------------------------------------------------------------- + # actions + # ------------------------------------------------------------- + def ensure_osk(self) -> None: + with _state_lock: + if self.osk_proc is not None and self.osk_proc.poll() is None: + return + try: + # Start hidden: focus state decides whether to SIGUSR2 it. + self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"]) + self.osk_visible = False + log(f"wvkbd started (hidden, args={WVKBD_ARGS!r})") + except FileNotFoundError: + log("wvkbd not found") + write_state(self.tablet, self.osk_visible) + + def kill_osk(self) -> None: + with _state_lock: + if self.osk_proc is not None and self.osk_proc.poll() is None: + self.osk_proc.terminate() + try: + self.osk_proc.wait(timeout=3) + except subprocess.TimeoutExpired: + self.osk_proc.kill() + self.osk_proc.wait() + log("wvkbd stopped") + self.osk_proc = None + self.osk_visible = False + write_state(self.tablet, False) + + def _signal_osk(self, sig: int) -> None: + if self.osk_proc is not None and self.osk_proc.poll() is None: + try: + os.kill(self.osk_proc.pid, sig) + except OSError: + pass + + def set_osk_visible(self, visible: bool) -> None: + if visible == self.osk_visible: + return + if visible: + if self.osk_proc is None or self.osk_proc.poll() is not None: + self.ensure_osk() + if self.osk_proc is None: + return + self._signal_osk(signal.SIGUSR2) + self.osk_visible = True + log("wvkbd show") + else: + self._signal_osk(signal.SIGUSR1) + self.osk_visible = False + log("wvkbd hide") + write_state(self.tablet, self.osk_visible) + + def toggle_osk(self) -> None: + with _state_lock: + if self.osk_proc is None or self.osk_proc.poll() is not None: + self.osk_proc = subprocess.Popen([WVKBD_PATH, *WVKBD_ARGS, "--hidden"]) + self.osk_visible = False + log("wvkbd started (hidden, manual toggle)") + write_state(self.tablet, self.osk_visible) + return + # wvkbd SIGRTMIN toggles visibility; we shadow its state here. + self.osk_visible = not self.osk_visible + self._signal_osk(signal.SIGRTMIN) + log(f"wvkbd toggled -> visible={self.osk_visible}") + write_state(self.tablet, self.osk_visible) + + def apply_transform(self, t: int) -> None: + if t != self.current_transform: + hyprctl_transform(self.monitor, t) + self.current_transform = t + log(f"transform -> {t}") + + # ------------------------------------------------------------- + # event handlers + # ------------------------------------------------------------- + def on_tablet_changed(self, new: bool) -> None: + if new: + self.apply_transform(transform_for(self.orientation)) + self.ensure_osk() + else: + self.apply_transform(0) + self.kill_osk() + write_state(self.tablet, self.osk_visible) + + def on_orientation(self, orient: str) -> None: + self.orientation = orient + if self.tablet: + self.apply_transform(transform_for(orient)) + # laptop mode: never rotate (gated by the switch above) + + # ------------------------------------------------------------- + def run(self) -> int: + have_evdev = self.open_evdev() + + # If we started mid-tablet, bring Hyprland to the current state right + # away instead of waiting for the next SW_TABLET_MODE edge. The OSK + # duty for the currently-focused window is applied below. + if self.tablet: + self.apply_transform(transform_for(self.orientation)) + self.ensure_osk() + cls = self.cur_focus_class() + if cls: + self.on_focus_changed(cls) + write_state(self.tablet, self.osk_visible) + + threads = [] + if have_evdev: + t = threading.Thread(target=self.evdev_thread, daemon=True) + t.start() + threads.append(t) + t = threading.Thread(target=self.sensor_thread, daemon=True) + t.start() + threads.append(t) + t = threading.Thread(target=self.focus_thread, daemon=True) + t.start() + threads.append(t) + t = threading.Thread(target=self.control_thread, daemon=True) + t.start() + threads.append(t) + + try: + while True: + time.sleep(3600) + except KeyboardInterrupt: + return 0 + + def cur_focus_class(self) -> str: + try: + out = subprocess.run( + [HYPRCTL, "activewindow", "-j"], + capture_output=True, + text=True, + timeout=3, + ) + info = json.loads(out.stdout) + return info.get("class") or "" + except Exception: + return "" + + +def main() -> int: + monitor = os.environ.get("HYPRLAND_TABLET_MONITOR", "eDP-1") + d = Daemon(monitor) + return d.run() + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/home-manager/modules/hyprland-tablet.nix b/home-manager/modules/hyprland-tablet.nix new file mode 100644 index 0000000..cb4dd8a --- /dev/null +++ b/home-manager/modules/hyprland-tablet.nix @@ -0,0 +1,219 @@ +{ + config, + pkgs, + ... +}: +let + palette = import ./palette.nix; + evdevPython = pkgs.python3.withPackages (ps: [ ps.evdev ]); + + # Text-capable window classes. wvkbd's own --auto relies on Hyprland's + # input-method-v2 protocol, which Hyprland advertises but never actually + # fires — so auto show/hide is driven here by watching activewindow events. + # Add classes (lowercase prefixes are allowed) for anything you type into. + textApps = [ + "kitty" + "alacritty" + "wezterm" + "konsole" + "com.mitchellh.ghostty" + "ghostty" + "xterm" + "firefox" + "librewolf" + "chromium" + "chromium-browser" + "google-chrome" + "microsoft-edge" + "brave-browser" + "thunderbird" + "com.github.xournalpp.xournalpp" + "code" + "code-url-handler" + "zen" + ]; + + # Shell-escaped arg list for the CLI fallback (same content as wvkbdFlags + # but space-joined for shell expansion). The daemon receives the JSON form. + # The font spec is double-quoted so it survives shell word-splitting. + wvkbdFlagsShell = pkgs.lib.concatStringsSep " " [ + "-l" + "simple" + "-H" + "300" + "-L" + "200" + "--fn" + "\"DejaVu Sans 24\"" + "--alpha" + "235" + "--bg" + "${palette.wvkbdBg}" + "--fg" + "${palette.surface}" + "--fg-sp" + "${palette.wvkbdFgSp}" + "--press" + "${palette.neon}" + "--press-sp" + "${palette.violet}" + "--text" + "${palette.text}" + "--text-sp" + "${palette.muted}" + "--text-press" + "${palette.ink}" + "--text-press-sp" + "${palette.ink}" + "--swipe" + "${palette.cyan}" + "--swipe-sp" + "${palette.violet}" + "--text-swipe" + "${palette.ink}" + "--text-swipe-sp" + "${palette.ink}" + ]; + + # wvkbd argv as a JSON array. The daemon splices this into place as a bare + # Python expression — a JSON array of strings IS valid Python (list of + # double-quoted strings), so no quote-escaping headaches at build time. + wvkbdArgs = [ + "-l" + "simple" + "-H" + "300" + "-L" + "200" + "--fn" + "DejaVu Sans 24" + "--alpha" + "235" + "--bg" + "${palette.wvkbdBg}" + "--fg" + "${palette.surface}" + "--fg-sp" + "${palette.wvkbdFgSp}" + "--press" + "${palette.neon}" + "--press-sp" + "${palette.violet}" + "--text" + "${palette.text}" + "--text-sp" + "${palette.muted}" + "--text-press" + "${palette.ink}" + "--text-press-sp" + "${palette.ink}" + "--swipe" + "${palette.cyan}" + "--swipe-sp" + "${palette.violet}" + "--text-swipe" + "${palette.ink}" + "--text-swipe-sp" + "${palette.ink}" + ]; + wvkbdArgsJson = builtins.toJSON wvkbdArgs; + + # Text-app allowlist as a JSON array, spliced the same way (valid Python + # list literal after substitution). + textAppsJson = builtins.toJSON textApps; + + # Place build-time paths into the daemon (replaceVars honours the + # @VAR@ placeholders) and run it with the evdev-enabled python. + daemon = pkgs.replaceVarsWith { + name = "hyprland-tablet-daemon"; + src = pkgs.writeScript "hyprland-tablet-daemon-src" '' + #!${evdevPython}/bin/python3 + ${builtins.readFile ./hyprland-tablet-daemon.py} + ''; + isExecutable = true; + replacements = { + EVDEV_DEVICE = "/dev/input/event17"; + WVKBD_PATH = "${pkgs.wvkbd}/bin/wvkbd-mobintl"; + WVKBD_ARGS = wvkbdArgsJson; + TEXT_APPS_JSON = textAppsJson; + MONITOR_SENSOR = "${pkgs.iio-sensor-proxy}/bin/monitor-sensor"; + HYPRCTL = "${pkgs.hyprland}/bin/hyprctl"; + }; + }; + + # Toggle/show/hide are forwarded to the daemon over a Unix datagram socket + # so manual control and focus-driven auto-hide share one visibility state. + cli = pkgs.writeShellScriptBin "hyprland-tablet" '' + set -eu + state_file="$XDG_RUNTIME_DIR/hyprland-tablet" + ctl_sock="$XDG_RUNTIME_DIR/hyprland-tablet.ctl" + + ctl_send() { + if [ -S "$ctl_sock" ]; then + ${pkgs.python3}/bin/python3 -c ' + import os, socket, sys + path = os.environ.get("XDG_RUNTIME_DIR", "/tmp") + "/hyprland-tablet.ctl" + s = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + s.sendto(sys.argv[1].encode(), path) + ' "$1" + return 0 + fi + return 1 + } + + osk_pid() { + pgrep -f 'wvkbd-mobintl' | head -n1 || true + } + + case "''${1:-}" in + status) + if [ -f "$state_file" ]; then + cat "$state_file" + else + echo '{"tablet":false,"osk":false}' + fi + ;; + # Toggle on-screen keyboard visibility. Manual control wins over the + # daemon's focus-derived state until the next activewindow change. + keyboard-toggle|osk-toggle) + ctl_send toggle || { + pid="$(osk_pid)" + if [ -z "$pid" ]; then + nohup ${pkgs.wvkbd}/bin/wvkbd-mobintl ${wvkbdFlagsShell} >/dev/null 2>&1 & + else + kill -SIGRTMIN "$pid" + fi + } + ;; + osk-show) + ctl_send show || { pid="$(osk_pid)"; [ -n "$pid" ] && kill -SIGUSR2 "$pid"; } + ;; + osk-hide) + ctl_send hide || { pid="$(osk_pid)"; [ -n "$pid" ] && kill -SIGUSR1 "$pid"; } + ;; + *) + echo "usage: hyprland-tablet {status|keyboard-toggle|osk-show|osk-hide}" >&2 + exit 2 + ;; + esac + ''; +in +{ + home.packages = [ cli ]; + + systemd.user.services.hyprland-tablet = { + Unit = { + Description = "Hyprland tablet-mode auto-rotation + on-screen keyboard"; + After = [ "graphical-session.target" ]; + PartOf = [ "graphical-session.target" ]; + }; + Service = { + Type = "simple"; + ExecStart = daemon; + Restart = "on-failure"; + }; + Install = { + WantedBy = [ "graphical-session.target" ]; + }; + }; +} diff --git a/home-manager/modules/hyprland.nix b/home-manager/modules/hyprland.nix new file mode 100644 index 0000000..9806091 --- /dev/null +++ b/home-manager/modules/hyprland.nix @@ -0,0 +1,1016 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + palette = import ./palette.nix; +in +# Per-user Hyprland + QuickShell configuration (home-manager). +# This is the Hyprland counterpart to home-manager/modules/desktop-user.nix — +# import this INSTEAD of desktop-user.nix when the user runs Hyprland. +{ + imports = [ + ./kitty.nix + ./firefox.nix + ./tools/just.nix + ]; + + home.activation.linkOnlyOfficeFonts = lib.hm.dag.entryAfter [ "writeBoundary" ] '' + FONT_DIR="/run/current-system/sw/share/X11/fonts" + USER_FONTS="$HOME/.local/share/fonts" + NATIVE_TARGET="$HOME/.local/share/onlyoffice/desktopeditors/data/fonts" + FLATPAK_TARGET="$HOME/.var/app/org.onlyoffice.desktopeditors/data/onlyoffice/desktopeditors/data/fonts" + + if [ -d "$FONT_DIR" ]; then + for TARGET_DIR in "$USER_FONTS" "$NATIVE_TARGET" "$FLATPAK_TARGET"; do + $DRY_RUN_CMD mkdir -p "$TARGET_DIR" + $DRY_RUN_CMD rm -f "$TARGET_DIR/AllFonts.js"* "$TARGET_DIR/fonts_thumbnail"* "$TARGET_DIR/font_selection.bin" "$TARGET_DIR/fonts.log" + $DRY_RUN_CMD cp -Lf "$FONT_DIR"/*.ttf "$FONT_DIR"/*.otf "$FONT_DIR"/*.ttc "$TARGET_DIR/" 2>/dev/null || true + done + fi + ''; + + # VS Code (same extensions set as the GNOME desktops). + programs.vscode = { + enable = true; + package = pkgs.vscode; + profiles.default = { + extensions = + with pkgs.vscode-extensions; + [ + bbenoist.nix + ms-python.python + dart-code.flutter + ] + ++ [ + (pkgs.vscode-utils.extensionFromVscodeMarketplace { + name = "opencode-go-for-copilot"; + publisher = "DenizhanDaklr"; + version = "0.1.18"; + sha256 = "0igdkgv6dra3zpd80lhwymdh216dqn8m8kqwsxh258wyk9fzgfk0"; + }) + ]; + }; + }; + + fonts.fontconfig.enable = true; + + # XDG Desktop Portal backend config (per-user scope; silences the + # xdg-desktop-portal >= 1.17 loader warning in this HM profile). + xdg.portal.config.common.default = "hyprland"; + + home.packages = with pkgs; [ + # Wayland user tools (screen recording) + wf-recorder + # QuickShell sci-fi theme fonts: Orbitron (display/clock) + Nerd Font + # symbol glyphs for status icons (wifi/battery/volume). + orbitron + nerd-fonts.symbols-only + ]; + + # ============================ Hyprland ============================ + wayland.windowManager.hyprland = { + enable = true; + # Hyprland 0.55+ uses Lua configs; hyprlang (.conf) is deprecated and + # will be removed in a future release. + configType = "lua"; + package = null; # use the NixOS-system Hyprland (programs.hyprland) + systemd.enable = true; + systemd.variables = [ "QT_QPA_PLATFORM" ]; + + # All configuration lives in extraConfig as raw Lua — this matches the + # upstream Hyprland Lua API exactly and is easier to maintain than + # fighting Home Manager's settings abstraction for binds / rules. + settings = { }; + extraConfig = '' + -- Auto-detect monitor + hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 1 }) + + -- Environment variables + hl.env("XDG_CURRENT_DESKTOP", "Hyprland") + hl.env("XDG_SESSION_TYPE", "wayland") + hl.env("XDG_SESSION_DESKTOP", "Hyprland") + hl.env("QT_QPA_PLATFORM", "wayland;xcb") + hl.env("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1") + hl.env("XCURSOR_SIZE", "24") + hl.env("HYPRCURSOR_SIZE", "24") + + -- Autostart + hl.on("hyprland.start", function() + hl.exec_cmd("quickshell") + hl.exec_cmd("hyprpaper") + -- Re-apply the wallpaper chosen in the SUPER+CTRL+A picker. hyprpaper + -- may not have its control socket up yet; qs-wall apply retries. + hl.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-wall apply'") + hl.exec_cmd("hypridle") + -- Launch the user's chosen autostart apps (toggled from the gear + -- quick-settings panel; qs-apps guards against double-starts). + hl.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-apps run'") + end) + + -- General configuration + hl.config({ + general = { + gaps_in = 6, + gaps_out = 12, + border_size = 2, + col = { + active_border = { colors = { "${palette.hyprRgba palette.blue "ee"}", "${palette.hyprRgba palette.neon "ee"}" }, angle = 45 }, + inactive_border = "${palette.hyprRgba palette.bgDark "66"}", + }, + layout = "dwindle", + resize_on_border = true, + }, + decoration = { + rounding = 12, + blur = { + enabled = true, + size = 12, + passes = 4, + vibrancy = 0.2, + noise = 0.01, + popups = true, + }, + shadow = { + enabled = true, + range = 16, + render_power = 3, + color = "${palette.hyprRgba palette.ink "66"}", + scale = 1.0, + }, + active_opacity = 1.0, + inactive_opacity = 0.9, + }, + animations = { + enabled = true, + }, + input = { + kb_layout = "gb", + follow_mouse = 1, + sensitivity = -0.1, + touchpad = { + natural_scroll = true, + tap_to_click = true, + -- int 0..2 in 0.56 (no drag_lock_threshold key); 1 = lifting the + -- finger mid-drag keeps the drag alive (laptop text-selection QoL) + drag_lock = 1, + }, + }, + misc = { + -- no Hyprland logo / anime mascot on empty desktops (hyprpaper + -- paints immediately, but this kills the startup flash) + disable_hyprland_logo = true, + force_default_wallpaper = 0, + }, + }) + + -- Scratchpad workspace: in the Lua config, workspace rules are set + -- via hl.workspace_rule (the top-level `workspace = ...` keyword from + -- hyprlang isn't a parser-level statement in 0.56.2). The + -- on_created_empty hook spawns a terminal in the special when it + -- opens empty; SUPER+` (toggle_special) reveals/hides it. + hl.workspace_rule({ + workspace = "special:term", + on_created_empty = "kitty", + }) + + + -- Animation curves (upstream example set + our custom bezier) + hl.curve("myBezier", { type = "bezier", points = { {0.05, 0.9}, {0.1, 1.05} } }) + hl.curve("easeOutQuint", { type = "bezier", points = { {0.23, 1}, {0.32, 1} } }) + hl.curve("almostLinear", { type = "bezier", points = { {0.5, 0.5}, {0.75, 1} } }) + hl.curve("quick", { type = "bezier", points = { {0.15, 0}, {0.1, 1} } }) + + -- Animations + hl.animation({ leaf = "windows", enabled = true, speed = 5, bezier = "myBezier" }) + hl.animation({ leaf = "windowsIn", enabled = true, speed = 4.5, bezier = "easeOutQuint", style = "popin 87%" }) + hl.animation({ leaf = "windowsOut", enabled = true, speed = 5, bezier = "default", style = "popin 80%" }) + hl.animation({ leaf = "fadeIn", enabled = true, speed = 1.73, bezier = "almostLinear" }) + hl.animation({ leaf = "fadeOut", enabled = true, speed = 1.46, bezier = "almostLinear" }) + hl.animation({ leaf = "fade", enabled = true, speed = 7, bezier = "quick" }) + hl.animation({ leaf = "border", enabled = true, speed = 5.39, bezier = "easeOutQuint" }) + -- Gradient border: animate the 45° active_border angle on focus change + -- (style=once plays per focus switch; loop would burn refresh-rate + -- frames continuously and hurt battery on this laptop). + hl.animation({ leaf = "borderangle", enabled = true, speed = 3, bezier = "easeOutQuint", style = "once" }) + hl.animation({ leaf = "workspaces", enabled = true, speed = 6, bezier = "default", style = "slidefade" }) + + -- Keybindings + local mod = "SUPER" + + hl.bind(mod .. " + Return", hl.dsp.exec_cmd("kitty")) + hl.bind(mod .. " + Q", hl.dsp.window.close()) + hl.bind(mod .. " + E", hl.dsp.exec_cmd("thunar")) + hl.bind(mod .. " + Space", hl.dsp.exec_cmd("wofi --show drun")) + -- Extended launcher modes (qs-launch → wofi): emoji picker, calculator + -- (qalc), DuckDuckGo search. Full .local/bin path: ~/.local/bin is not + -- on the Hyprland session PATH (same as the qs-wall/qs-apps autostart). + hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-launch emoji'")) + hl.bind(mod .. " + SHIFT + C", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-launch calc'")) + hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-launch search'")) + hl.bind(mod .. " + F", hl.dsp.window.fullscreen()) + -- Float toggle + clamp into the monitor work-area + bring to front. + -- The QuickShell bar is a Top layer surface, always drawn above regular + -- windows, so a floating window that keeps fullscreen geometry tucks its + -- title bar/menus under the bar. Clamping into the reserved work-area + -- (below the bar) keeps the whole window visible. + hl.bind(mod .. " + V", function() + local wasFloating = (hl.get_active_window() or {}).floating == true + hl.dispatch(hl.dsp.window.float({ action = "toggle" })) + local w = hl.get_active_window() + if w ~= nil and w.floating and not wasFloating then + local m = w.monitor + if m ~= nil then + local wa = { + x = m.x + m.reserved.left, + y = m.y + m.reserved.top, + w = m.width - m.reserved.left - m.reserved.right, + h = m.height - m.reserved.top - m.reserved.bottom, + } + local x, y = w.at.x, w.at.y + local ww, wh = w.size.x, w.size.y + -- keep the window from sticking out below/right of the bar + if y + wh > wa.y + wa.h then wh = wa.y + wa.h - y end + if x + ww > wa.x + wa.w then ww = wa.x + wa.w - x end + -- and never let it ride up under the bar + if y < wa.y then + y = wa.y + if y + wh > wa.y + wa.h then wh = wa.y + wa.h - y end + end + if ww ~= w.size.x or wh ~= w.size.y then + hl.dispatch(hl.dsp.window.resize({ x = ww, y = wh })) + end + if x ~= w.at.x or y ~= w.at.y then + hl.dispatch(hl.dsp.window.move({ x = x, y = y })) + end + end + end + hl.dispatch(hl.dsp.window.bring_to_top()) + end) + hl.bind(mod .. " + P", hl.dsp.window.pseudo()) + hl.bind(mod .. " + M", hl.dsp.exit()) + + hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" })) + hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" })) + hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" })) + hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" })) + hl.bind(mod .. " + H", hl.dsp.focus({ direction = "left" })) + hl.bind(mod .. " + L", hl.dsp.focus({ direction = "right" })) + hl.bind(mod .. " + K", hl.dsp.focus({ direction = "up" })) + hl.bind(mod .. " + J", hl.dsp.focus({ direction = "down" })) + + for i = 1, 9 do + hl.bind(mod .. " + " .. i, hl.dsp.focus({ workspace = i })) + hl.bind(mod .. " + SHIFT + " .. i, hl.dsp.window.move({ workspace = i })) + end + + hl.bind("Print", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-shot pick'")) + hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-shot area'")) + hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-shot screen'")) + hl.bind(mod .. " + L", hl.dsp.exec_cmd("hyprlock")) + hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("wlogout")) + + -- App switching + clipboard history + keybind cheatsheet + notif center + ws overview + -- (QuickShell popups via IPC) + -- Classic alt-tab window cycling (next) + bring to top, alt-shift-tab backwards. + hl.bind("ALT + Tab", function() + hl.dispatch(hl.dsp.window.cycle_next()) + hl.dispatch(hl.dsp.window.bring_to_top()) + end) + hl.bind("ALT + SHIFT + Tab", function() + hl.dispatch(hl.dsp.window.cycle_next({ direction = "prev" })) + hl.dispatch(hl.dsp.window.bring_to_top()) + end) + hl.bind(mod .. " + Tab", hl.dsp.exec_cmd("wofi --show window")) + hl.bind(mod .. " + W", hl.dsp.exec_cmd("qs ipc call ws toggle")) + hl.bind(mod .. " + CTRL + V", hl.dsp.exec_cmd("qs ipc call clip toggle")) + hl.bind(mod .. " + CTRL + H", hl.dsp.exec_cmd("qs ipc call help toggle")) + hl.bind(mod .. " + CTRL + N", hl.dsp.exec_cmd("qs ipc call notif toggle")) + hl.bind(mod .. " + CTRL + C", hl.dsp.exec_cmd("qs ipc call cal toggle")) + hl.bind(mod .. " + CTRL + A", hl.dsp.exec_cmd("qs ipc call wall toggle")) + -- tablet on-screen keyboard toggle (wvkbd; see hyprland-tablet). Not a + -- qs IPC call because the keyboard is a Wayland surface, not Quickshell. + hl.bind(mod .. " + CTRL + K", hl.dsp.exec_cmd("hyprland-tablet keyboard-toggle")) + + -- Scratchpad terminal (special workspace that follows you around) + hl.bind(mod .. " + grave", hl.dsp.workspace.toggle_special("term")) + hl.bind(mod .. " + SHIFT + grave", hl.dsp.window.move({ workspace = "special:term" })) + -- Pull the focused window OUT of the scratchpad back to the main + -- workspace. Open the scratchpad first (SUPER+grave) so the + -- scratchpad's window has focus, then SUPER+CTRL+grave ejects it. + hl.bind(mod .. " + CTRL + grave", hl.dsp.window.move({ workspace = "1" })) + + -- Laptop media keys. locked = also work while hyprlock is up, + -- repeating = auto-repeat when held (volume ramps smoothly). + hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pamixer -i 5"), { locked = true, repeating = true }) + hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pamixer -d 5"), { locked = true, repeating = true }) + hl.bind("XF86AudioMute", hl.dsp.exec_cmd("pamixer -t"), { locked = true }) + hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("pamixer --default-source -t"), { locked = true }) + hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) + hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl pause"), { locked = true }) + hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) + hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) + -- brightness changes are not observable from QML, so the key command + -- itself pokes the QuickShell OSD via IPC after setting the level. + -- NB function is named "bright" not "show" — the qs CLI treats "show" + -- as its own subcommand token and never reaches the handler. + hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("sh -c 'brightnessctl set 5%+ && qs ipc call osd bright'"), { locked = true, repeating = true }) + hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("sh -c 'brightnessctl set 5%- && qs ipc call osd bright'"), { locked = true, repeating = true }) + + -- Resize submap: SUPER+R enters, arrows/HJKL resize the focused + -- window, Escape (or any reset) leaves. NOTE: 0.56.2's key parser only + -- splits on '+', so one key per hl.bind call (no "left, h" lists). + hl.define_submap("resize", function() + local function rsz(dx, dy, keys) + for _, k in ipairs(keys) do + hl.bind(k, hl.dsp.window.resize({ x = dx, y = dy, relative = true })) + end + end + rsz(-20, 0, { "left", "h" }) + rsz(20, 0, { "right", "l" }) + rsz(0, -20, { "up", "k" }) + rsz(0, 20, { "down", "j" }) + hl.bind("Escape", hl.dsp.submap("reset")) + end) + hl.bind(mod .. " + R", hl.dsp.submap("resize")) + + -- Smart gaps: w[tv1] is a gapless "watch" workspace; parking a window + -- there (SUPER+CTRL+F) gives borderless fullscreen-ish video without + -- actually fullscreening. f[1] kills gaps under any real fullscreen. + hl.workspace_rule({ workspace = "w[tv1]", gaps_out = 0, gaps_in = 0 }) + hl.workspace_rule({ workspace = "f[1]", gaps_out = 0, gaps_in = 0 }) + hl.window_rule({ name = "no-gaps-wtv1", match = { float = false, workspace = "w[tv1]" }, border_size = 0, rounding = 0 }) + hl.window_rule({ name = "no-gaps-f1", match = { float = false, workspace = "f[1]" }, border_size = 0, rounding = 0 }) + + local gapless = false + hl.bind(mod .. " + CTRL + F", function() + if gapless then + hl.dispatch(hl.dsp.window.move({ workspace = "previous" })) + else + hl.dispatch(hl.dsp.window.move({ workspace = "w[tv1]" })) + end + gapless = not gapless + end) + + -- Flash focus: briefly override the border colour on window switch and + -- revert via a oneshot timer (hyprfocus as pure Lua, no plugin). + -- NB 0.56 set_prop accepts "active_border_color"/"inactive_border_color" + -- (NOT "bordercol"/"border_color" — the latter is a rule-effect name + -- only). Reverting to the config default colour is visually identical + -- to clearing the override, since the inactive border reads a + -- separate, un-overridden slot. + hl.on("window.active", function(w) + if w == nil then return end + hl.dispatch(hl.dsp.window.set_prop({ prop = "active_border_color", value = "rgba(00e5ffff)" })) + hl.timer(function() + hl.dispatch(hl.dsp.window.set_prop({ prop = "active_border_color", value = "rgba(7aa2f7ee)" })) + end, { timeout = 180, type = "oneshot" }) + end) + + -- Night light (blue-light filter) toggle: hyprsunset keeps the colour + -- temperature property alive while running; killing it restores normal. + local nightLight = false + hl.bind(mod .. " + SHIFT + N", function() + if nightLight then + hl.exec_cmd("pkill hyprsunset") + else + hl.exec_cmd("hyprsunset -t 4500") + end + nightLight = not nightLight + end) + + -- Mouse binds + hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) + hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) + + -- Window rules + hl.window_rule({ match = { class = "org.pulseaudio.pavucontrol" }, float = true }) + hl.window_rule({ match = { title = "Picture%-in%-Picture" }, float = true }) + hl.window_rule({ match = { title = "Firefox %-%- Picture%-in%-Picture" }, float = true }) + + -- Layer rules (frosted-glass blur for the QuickShell bar) + hl.layer_rule({ match = { namespace = "qs%-neon%-bar" }, blur = true }) + hl.layer_rule({ match = { namespace = "qs%-neon%-bar" }, ignore_alpha = 0.3 }) + ''; + }; + + # ============================ QuickShell ============================ + # QuickShell looks for ~/.config/quickshell/shell.qml by default. + # The pinned-flake info placeholder (@FLAKEINFO@ in the QML) is resolved at + # build time from flake.lock so the stats popup's system/badge chip shows + # the nixpkgs rev/date without needing network access at runtime. The + # @THEMECOLORS@ placeholder carries the build-time shared palette so the + # bar never flashes an unthemed frame before the wallpaper palette applies. + xdg.configFile."quickshell/shell.qml".text = + builtins.replaceStrings + [ "@FLAKEINFO@" "@THEMECOLORS@" ] + [ + (builtins.toJSON ( + let + lock = builtins.fromJSON (builtins.readFile ../../flake.lock); + nixpkgs = lock.nodes.nixpkgs.locked; + in + { + rev = nixpkgs.rev; + lastModified = nixpkgs.lastModified; + repo = nixpkgs.repo; + owner = nixpkgs.owner; + } + )) + (builtins.toJSON { + glass = palette.hash palette.glass; + glassPanel = palette.hash palette.glassPanel; + surface = palette.hash palette.surface; + line = palette.hash palette.line; + text = palette.hash palette.text; + muted = palette.hash palette.muted; + ink = palette.hash palette.ink; + neon = palette.hash palette.neon; + violet = palette.hash palette.violet; + magenta = palette.hash palette.magenta; + danger = palette.hash palette.danger; + }) + ] + (builtins.readFile ./quickshell-shell.qml); + # Same-directory sibling component used by shell.qml (implicit QML import). + xdg.configFile."quickshell/SciSlider.qml".text = builtins.readFile ./quickshell-slider.qml; + # Keybind cheatsheet data rendered by the SUPER+CTRL+H help popup. Keep in + # sync with the hl.bind() list in extraConfig above. + xdg.configFile."quickshell/keybinds.json".text = builtins.toJSON [ + { + cat = "SESSION"; + items = [ + { + keys = "SUPER + RETURN"; + desc = "terminal (kitty)"; + } + { + keys = "SUPER + SPACE"; + desc = "app launcher (wofi)"; + } + { + keys = "SUPER + SHIFT + E"; + desc = "emoji picker"; + } + { + keys = "SUPER + SHIFT + C"; + desc = "calculator (qalc)"; + } + { + keys = "SUPER + SHIFT + D"; + desc = "DuckDuckGo search"; + } + { + keys = "Print"; + desc = "screenshot (area or full screen)"; + } + { + keys = "SUPER + SHIFT + S"; + desc = "screenshot (area)"; + } + { + keys = "SUPER + SHIFT + R"; + desc = "screenshot (full screen)"; + } + { + keys = "SUPER + E"; + desc = "file manager (thunar)"; + } + { + keys = "ALT + TAB"; + desc = "switch between windows (hold ALT, keep pressing TAB)"; + } + { + keys = "ALT + SHIFT + TAB"; + desc = "switch backwards through windows"; + } + { + keys = "SUPER + TAB"; + desc = "window switcher (list)"; + } + { + keys = "SUPER + GRAVE"; + desc = "scratchpad terminal (kitty auto-spawns); toggle"; + } + { + keys = "SUPER + SHIFT + GRAVE"; + desc = "send window to scratchpad"; + } + { + keys = "SUPER + CTRL + GRAVE"; + desc = "pull window from scratchpad to main workspace (open scratchpad first)"; + } + { + keys = "SUPER + L"; + desc = "lock screen"; + } + { + keys = "SUPER + M"; + desc = "exit hyprland"; + } + { + keys = "SUPER + SHIFT + Q"; + desc = "power / logout menu"; + } + ]; + } + { + cat = "WINDOWS"; + items = [ + { + keys = "SUPER + Q"; + desc = "close window"; + } + { + keys = "SUPER + ARROWS / H K L J"; + desc = "focus neighbouring window"; + } + { + keys = "SUPER + F"; + desc = "fullscreen"; + } + { + keys = "SUPER + V"; + desc = "toggle floating"; + } + { + keys = "SUPER + P"; + desc = "pseudotile"; + } + { + keys = "SUPER + R"; + desc = "resize mode (arrows/HJKL, esc exits)"; + } + { + keys = "SUPER + LEFT DRAG"; + desc = "move window"; + } + { + keys = "SUPER + RIGHT DRAG"; + desc = "resize window"; + } + { + keys = "SUPER + CTRL + F"; + desc = "watch mode: gapless media workspace / return"; + } + ]; + } + { + cat = "WORKSPACES"; + items = [ + { + keys = "SUPER + 1..9"; + desc = "switch workspace"; + } + { + keys = "SUPER + SHIFT + 1..9"; + desc = "move window to workspace"; + } + ]; + } + { + cat = "MEDIA & SYSTEM"; + items = [ + { + keys = "PRINT / SUPER + SHIFT + S"; + desc = "screenshot area to clipboard"; + } + { + keys = "SUPER + SHIFT + R"; + desc = "screenshot screen to clipboard"; + } + { + keys = "VOL UP / VOL DOWN / MUTE"; + desc = "volume (neon OSD, works locked)"; + } + { + keys = "MIC MUTE"; + desc = "toggle microphone"; + } + { + keys = "PLAY / NEXT / PREV"; + desc = "media playback (MPRIS)"; + } + { + keys = "BRIGHT UP / DOWN"; + desc = "backlight (neon OSD)"; + } + { + keys = "SUPER + SHIFT + N"; + desc = "night light (blue-light filter)"; + } + ]; + } + { + cat = "INTERFACE"; + items = [ + { + keys = "SUPER + CTRL + V"; + desc = "clipboard history (pick copies + pastes)"; + } + { + keys = "SUPER + CTRL + H"; + desc = "this cheatsheet"; + } + { + keys = "SUPER + CTRL + N"; + desc = "notification center (history, DND, clear)"; + } + { + keys = "SUPER + CTRL + C"; + desc = "calendar + weather (Nextcloud events, Open-Meteo)"; + } + { + keys = "SUPER + W"; + desc = "workspace overview (jump to any workspace)"; + } + { + keys = "SUPER + CTRL + A"; + desc = "wallpaper picker (arrows preview live, click to apply)"; + } + { + keys = "SUPER + CTRL + K"; + desc = "tablet on-screen keyboard toggle (wvkbd)"; + } + { + keys = "GEAR button"; + desc = "quick settings: volume, brightness, profile, keep-awake, power"; + } + { + keys = "SPEAKER icon"; + desc = "volume slider popup"; + } + { + keys = "WI-FI button"; + desc = "network menu"; + } + { + keys = "BLUETOOTH button"; + desc = "bluetooth devices (power, pair, connect, scan)"; + } + { + keys = "BELL button"; + desc = "notification center; MUTE icon means DND is on"; + } + { + keys = "COFFEE icon"; + desc = "keep-awake is on (no lock/suspend)"; + } + ]; + } + ]; + + # Clipboard history: a systemd user service tees every clipboard change + # into cliphist; the QuickShell bar renders the picker (SUPER+CTRL+V via + # `qs ipc call clip toggle`). Defaults to hyprland-session.target via + # wayland.systemd.target (systemd.enable above). + services.cliphist.enable = true; + + # ============================ Desktop bits (Wayland) ============================ + + # GTK theme + cursors (matching the GNOME hosts for consistency) + gtk = { + enable = true; + gtk4.theme = config.gtk.theme; + theme = { + name = "catppuccin-frappe-blue-standard"; + package = pkgs.catppuccin-gtk; + }; + iconTheme = { + name = "Papirus-Dark"; + package = pkgs.papirus-icon-theme; + }; + cursorTheme = { + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; + }; + }; + + home.pointerCursor = { + enable = true; + name = "Bibata-Modern-Classic"; + package = pkgs.bibata-cursors; + gtk.enable = true; + x11.enable = true; + }; + + # Wallpaper for hyprpaper (Scifi.jpg = user-provided sci-fi artwork; + # tokyo-night and neon-horizon kept as fallback assets). + home.file."Pictures/wallpapers/tokyo-night.png".source = ../../assets/wallpapers/tokyo-night.png; + home.file."Pictures/wallpapers/neon-horizon.png".source = ../../assets/wallpapers/neon-horizon.png; + home.file."Pictures/wallpapers/Scifi.jpg".source = ../../assets/wallpapers/Scifi.jpg; + # hyprpaper >= 0.8 uses a hyprlang special-category block per wallpaper + # (`wallpaper { monitor = ... }`); the legacy `preload/wallpaper = ,img` + # key-value lines are silently ignored ("monitor has no target"). `*` is the + # wildcard monitor name. Note: `preload` is no longer a config keyword. + xdg.configFile."hypr/hyprpaper.conf".text = '' + ipc = 1 + splash = 0 + + wallpaper { + monitor = * + path = ${config.home.homeDirectory}/Pictures/wallpapers/Scifi.jpg + fit_mode = cover + } + ''; + + # Wallpaper picker (SUPER+CTRL+A): backend script + PATH wrapper. The state + # file (not hyprpaper.conf) records the user's choice; the login autostart + # above re-applies it after hyprpaper starts. + xdg.configFile."quickshell/qs-wall.py".text = builtins.readFile ./quickshell-wall.py; + home.file.".local/bin/qs-wall" = { + executable = true; + text = '' + #!/bin/sh + exec ${pkgs.python3}/bin/python3 \ + ${config.home.homeDirectory}/.config/quickshell/qs-wall.py "$@" + ''; + }; + + # Bluetooth manager (BT status-bar button): backend script + PATH wrapper. + # $BLUECTL points at the bluez binary so the wrapper works regardless of PATH; + # `hardware.bluetooth` is enabled in modules/desktop/gui.nix. + xdg.configFile."quickshell/qs-bt.py".text = builtins.readFile ./quickshell-bt.py; + home.file.".local/bin/qs-bt" = { + executable = true; + text = '' + #!/bin/sh + export BLUECTL=${pkgs.bluez}/bin/bluetoothctl + exec ${pkgs.python3}/bin/python3 \ + ${config.home.homeDirectory}/.config/quickshell/qs-bt.py "$@" + ''; + }; + + # System-stats probe (stats status-bar chip + popup): backend script + PATH + # wrapper. The bar polls it slowly (15s) to stay battery-cheap (~250ms idle + # CPU sample); the stats popup refreshes every 3s while open. Reads /proc + + # /sys (kernel hwmon zones), so no lm_sensors runtime dependency. + xdg.configFile."quickshell/qs-stats.py".text = builtins.readFile ./quickshell-stats.py; + home.file.".local/bin/qs-stats" = { + executable = true; + text = '' + #!/bin/sh + exec ${pkgs.python3}/bin/python3 \ + ${config.home.homeDirectory}/.config/quickshell/qs-stats.py "$@" + ''; + }; + + # Screenshot capture (grimblast area/full screen) + QuickShell toast preview + # with Open / Copy actions: backend script + PATH wrapper. Binary paths + # injected via QS_* env vars (same pattern as qs-launch). "pick" shows a + # wofi chooser (select area / full screen), area/screen capture directly. + xdg.configFile."quickshell/qs-shot.py".text = builtins.readFile ./quickshell-shot.py; + home.file.".local/bin/qs-shot" = { + executable = true; + text = '' + #!/bin/sh + export QS_GRIMBLAST=${pkgs.grimblast}/bin/grimblast + export QS_WOFI=${pkgs.wofi}/bin/wofi + export QS_NOTIFY=${pkgs.libnotify}/bin/notify-send + export QS_WLCOPY=${pkgs.wl-clipboard}/bin/wl-copy + export QS_XDGO=${pkgs.xdg-utils}/bin/xdg-open + exec ${pkgs.python3}/bin/python3 \ + ${config.home.homeDirectory}/.config/quickshell/qs-shot.py "$@" + ''; + }; + + # Extended launcher (emoji / calculator / DuckDuckGo search via wofi): + # backend script + PATH wrapper. Binary paths injected via QS_* env vars so + # the modes work regardless of PATH (same pattern as qs-bt's $BLUECTL). + xdg.configFile."quickshell/qs-launch.py".text = builtins.readFile ./quickshell-launch.py; + home.file.".local/bin/qs-launch" = { + executable = true; + text = '' + #!/bin/sh + export QS_WOFI=${pkgs.wofi}/bin/wofi + export QS_QALC=${pkgs.libqalculate}/bin/qalc + export QS_WLCOPY=${pkgs.wl-clipboard}/bin/wl-copy + export QS_NOTIFY=${pkgs.libnotify}/bin/notify-send + export QS_XDGO=${pkgs.xdg-utils}/bin/xdg-open + exec ${pkgs.python3}/bin/python3 \ + ${config.home.homeDirectory}/.config/quickshell/qs-launch.py "$@" + ''; + }; + + # hyprlock media probe: prints MPRIS artist/title via playerctl, or nothing + # (empty label text = label hidden). playerctl's {{...}} format braces must + # live in a script — hyprlock parses cmd text as math expressions and chokes + # on a bare {{ }} (hyprwm/hyprlock#894). Colors from palette. + home.file.".local/bin/hyprlock-media" = { + executable = true; + text = '' + #!/bin/sh + set -e + title=$(${pkgs.playerctl}/bin/playerctl metadata --format '{{ title }}' 2>/dev/null) || exit 0 + artist=$(${pkgs.playerctl}/bin/playerctl metadata --format '{{ artist }}' 2>/dev/null || true) + printf ' %s — %s\n' \ + "${palette.hash palette.violet}" "$artist" "$title" + ''; + }; + + # hyprlock battery probe: prints capacity + status, or nothing on machines + # without a battery (empty label text = label hidden). + home.file.".local/bin/hyprlock-battery" = { + executable = true; + text = '' + #!/bin/sh + set -e + cap=$(cat /sys/class/power_supply/BAT0/capacity 2>/dev/null) || exit 0 + st=$(cat /sys/class/power_supply/BAT0/status 2>/dev/null) + case "$st" in + Charging) col=${palette.hash palette.neon} ;; + *) col=${palette.hash palette.muted} ;; + esac + [ "$cap" -le 15 ] && col=${palette.hash palette.danger} + printf '%s%% %s\n' "$col" "$cap" "$st" + ''; + }; + + # hyprlock — matches the QuickShell sci-fi theme: blurred Scifi.jpg + # background, cyan-accented password field, Orbitron clock. + xdg.configFile."hypr/hyprlock.conf".text = '' + general { + ignore_empty_input = true + } + + background { + monitor = + path = ${config.home.homeDirectory}/Pictures/wallpapers/Scifi.jpg + blur_size = 6 + blur_passes = 3 + noise = 0.01 + vibrancy = 0.2 + brightness = 0.35 + } + + input-field { + monitor = + size = 320, 80 + outline_thickness = 2 + dots_size = 0.15 + dots_spacing = 0.6 + dots_center = true + outer_color = ${palette.lockRgba palette.neon "1.0"} + inner_color = ${palette.lockRgba palette.panel "0.9"} + font_color = ${palette.lockRgba palette.danger "1.0"} + fade_on_empty = false + placeholder_text = password // + halign = center + valign = center + position = 0, -15% + } + + label { + monitor = + text = $TIME + color = ${palette.lockRgba palette.neon "1.0"} + font_size = 64 + font_family = Orbitron + halign = center + valign = center + position = 0, 12% + } + + label { + monitor = + text = // SYSTEM LOCKED // + color = ${palette.lockRgba palette.muted "1.0"} + font_size = 14 + font_family = Orbitron + halign = center + valign = center + position = 0, 4% + } + + label { + monitor = + text = cmd[] ${config.home.homeDirectory}/.local/bin/hyprlock-media + color = ${palette.lockRgba palette.muted "1.0"} + font_size = 16 + font_family = Orbitron + halign = center + valign = center + position = 0, 2% + } + + label { + monitor = + text = cmd[] ${config.home.homeDirectory}/.local/bin/hyprlock-battery + color = ${palette.lockRgba palette.muted "1.0"} + font_size = 14 + font_family = Orbitron + halign = center + valign = bottom + position = 0, 6% + } + ''; + + # hypridle — lock after 5 min idle, dim screen before locking + xdg.configFile."hypr/hypridle.conf".text = '' + general { + lock_cmd = pidof hyprlock || hyprlock + before_sleep_cmd = loginctl lock-session + # Under the Lua config, hyprctl dispatch evals the argument as Lua, + # so the legacy "dpms on" syntax errors out — use hl.dsp.dpms({on=true}) + after_sleep_cmd = hyprctl dispatch "hl.dsp.dpms({on=true})" + } + + listener { + timeout = 300 + on-timeout = loginctl lock-session + } + + listener { + timeout = 330 + on-timeout = hyprctl dispatch "hl.dsp.dpms({off=true})" + on-resume = hyprctl dispatch "hl.dsp.dpms({on=true})" + } + + # Suspend after 15 min idle (lock at 5 min). `sudo systemctl` because + # polkit does not recognise the greetd session as "active" — same reason + # the networkmanager group membership is needed (see desktop/hyprland.nix). + listener { + timeout = 900 + on-timeout = sudo systemctl suspend + on-resume = hyprctl dispatch "hl.dsp.dpms({on=true})" + } + ''; + + # wofi — app launcher (sci-fi dark glass + neon, matches QuickShell theme). + # No `style=` line: wofi auto-loads $XDG_CONFIG_HOME/wofi/style.css, and its + # `style=` option does NOT expand `~` (loads nothing → unstyled black box). + xdg.configFile."wofi/config".text = '' + width=35% + height=45% + location=center + opacity=90 + hide_scroll=true + allow_images=true + image_size=32 + key_expand=Right + ''; + xdg.configFile."wofi/style.css".text = '' + window { + background-color: ${palette.cssRgba palette.panel "0.92"}; + border: 2px solid ${palette.hash palette.neon}; + border-radius: 14px; + } + #input { + background-color: ${palette.cssRgba palette.ink "0.9"}; + color: ${palette.hash palette.text}; + border: 1px solid ${palette.hash palette.line}; + border-radius: 10px; + margin: 10px; + padding: 8px; + } + #input:focus { + border: 1px solid ${palette.hash palette.neon}; + } + #outer-box { + margin: 0; + } + #inner-box { + margin: 0 6px; + } + * { + font-family: "FiraCode Nerd Font", monospace; + font-size: 14px; + } + #entry { + padding: 8px 10px; + border-radius: 8px; + } + #entry:selected { + background-color: ${palette.cssRgba palette.neon "0.15"}; + border: 1px solid ${palette.hash palette.neon}; + border-radius: 8px; + } + #entry image { + margin-right: 10px; + min-width: 32px; + min-height: 32px; + } + #entry label { + color: ${palette.hash palette.muted}; + } + #entry:selected label { + color: ${palette.hash palette.neon}; + } + #entry list { + margin-top: 4px; + } + #entry list row { + padding: 4px 8px; + border-radius: 6px; + } + #entry list row:selected { + background-color: ${palette.cssRgba palette.neon "0.25"}; + } + #scroll { + margin: 4px; + } + ''; +} diff --git a/home-manager/modules/kitty.nix b/home-manager/modules/kitty.nix new file mode 100644 index 0000000..ae029fb --- /dev/null +++ b/home-manager/modules/kitty.nix @@ -0,0 +1,82 @@ +{ pkgs, ... }: + +let + palette = import ./palette.nix; +in +{ + programs.kitty = { + enable = true; + font = { + name = "FiraCode Nerd Font"; + size = 12; + }; + settings = { + enabled_layouts = "tall:bias=50;full_size=1;mirrored=false,fat,grid,stack"; + scrollback_lines = 10000; + enable_audio_bell = false; + update_check_interval = 0; + shell_integration = "enabled"; + + # Performance + repaint_delay = 10; + input_delay = 3; + sync_to_monitor = "yes"; + + # Window layout + window_padding_width = 10; + confirm_os_window_close = 0; + background_opacity = "0.85"; + dynamic_background_opacity = "yes"; + + # Tabs (colors live in colors.conf so `qs-theme` can override them) + tab_bar_style = "powerline"; + }; + keybindings = { + "ctrl+shift+c" = "copy_to_clipboard"; + "ctrl+shift+v" = "paste_from_clipboard"; + "ctrl+shift+enter" = "new_window"; + "ctrl+shift+]" = "next_window"; + "ctrl+shift+[" = "previous_window"; + "ctrl+shift+l" = "next_layout"; + }; + + # Colors live in an include so a wallpaper-driven palette (`qs-theme`, + # via matugen) can overwrite them without touching the rest of kitty.conf. + extraConfig = "include colors.conf"; + }; + + xdg.configFile."kitty/colors.conf".text = '' + # Color palette (Tokyo Night inspired) — declared in home-manager. + # Regenerated by `~/.local/bin/qs-theme ` (matugen) at runtime. + background ${palette.hash palette.bgDark} + foreground ${palette.hash palette.muted} + selection_background ${palette.hash palette.selection} + selection_foreground ${palette.hash palette.text} + url_color ${palette.hash palette.teal} + cursor ${palette.hash palette.text} + cursor_text_color ${palette.hash palette.bgDark} + + active_tab_background ${palette.hash palette.blue} + active_tab_foreground ${palette.hash palette.tabFgBright} + inactive_tab_background ${palette.hash palette.tabBg} + inactive_tab_foreground ${palette.hash palette.tabFg} + + # ANSI 16 + color0 ${palette.hash palette.color0} + color1 ${palette.hash palette.danger} + color2 ${palette.hash palette.green} + color3 ${palette.hash palette.yellow} + color4 ${palette.hash palette.blue} + color5 ${palette.hash palette.purple} + color6 ${palette.hash palette.cyan} + color7 ${palette.hash palette.muted} + color8 ${palette.hash palette.color8} + color9 ${palette.hash palette.danger} + color10 ${palette.hash palette.green} + color11 ${palette.hash palette.yellow} + color12 ${palette.hash palette.blue} + color13 ${palette.hash palette.purple} + color14 ${palette.hash palette.cyan} + color15 ${palette.hash palette.text} + ''; +} diff --git a/home-manager/modules/matugen-themes/kitty-colors.conf.template b/home-manager/modules/matugen-themes/kitty-colors.conf.template new file mode 100644 index 0000000..ec99b14 --- /dev/null +++ b/home-manager/modules/matugen-themes/kitty-colors.conf.template @@ -0,0 +1,33 @@ +background {{colors.surface.dark.hex}} +foreground {{colors.on_surface.dark.hex}} +selection_background {{colors.primary.dark.hex}} +selection_foreground {{colors.on_primary.dark.hex}} +url_color {{colors.tertiary.dark.hex}} +cursor {{colors.on_surface.dark.hex}} +cursor_text_color {{colors.surface.dark.hex}} + +active_tab_background {{colors.primary.dark.hex}} +active_tab_foreground {{colors.on_primary.dark.hex}} +inactive_tab_background {{colors.surface_container_low.dark.hex}} +inactive_tab_foreground {{colors.on_surface_variant.dark.hex}} + +# ANSI 16 — mapped to Material-You roles (NOT base16: matugen's base16 dark +# accents collapse to near-black, making colored terminal text unreadable). +# Material roles are the dark-mode light-tinted variants, all >10:1 contrast +# on the surface, so both text and UI stay legible. +color0 {{colors.surface.dark.hex}} +color1 {{colors.error.dark.hex}} +color2 {{colors.tertiary.dark.hex}} +color3 {{colors.secondary.dark.hex}} +color4 {{colors.primary.dark.hex}} +color5 {{colors.tertiary.dark.hex}} +color6 {{colors.secondary.dark.hex}} +color7 {{colors.on_surface.dark.hex}} +color8 {{colors.outline.dark.hex}} +color9 {{colors.error.dark.hex}} +color10 {{colors.tertiary.dark.hex}} +color11 {{colors.secondary.dark.hex}} +color12 {{colors.primary.dark.hex}} +color13 {{colors.tertiary.dark.hex}} +color14 {{colors.secondary.dark.hex}} +color15 {{colors.on_surface.dark.hex}} \ No newline at end of file diff --git a/home-manager/modules/matugen-themes/theme.json.template b/home-manager/modules/matugen-themes/theme.json.template new file mode 100644 index 0000000..8004d9e --- /dev/null +++ b/home-manager/modules/matugen-themes/theme.json.template @@ -0,0 +1,11 @@ +{ + "surface": "{{colors.surface.dark.hex}}", + "ink": "{{colors.background.dark.hex}}", + "text": "{{colors.on_surface.dark.hex}}", + "muted": "{{colors.on_surface_variant.dark.hex}}", + "line": "{{colors.outline.dark.hex}}", + "neon": "{{colors.primary.dark.hex}}", + "violet": "{{colors.tertiary.dark.hex}}", + "magenta": "{{colors.secondary.dark.hex}}", + "danger": "{{colors.error.dark.hex}}" +} \ No newline at end of file diff --git a/home-manager/modules/matugen.nix b/home-manager/modules/matugen.nix new file mode 100644 index 0000000..f8c998b --- /dev/null +++ b/home-manager/modules/matugen.nix @@ -0,0 +1,55 @@ +# Wallpaper-driven Material-You theming (matugen) for the QuickShell shell. +# `qs-theme ` regenerates: +# - ~/.cache/quickshell/theme.json → live-repaints the bar (FileView watcher) +# - ~/.config/kitty/colors.conf → kitty palette + ANSI 16 +# The Nix-declared colors.conf symlink is restored on the next home-manager +# rebuild; until then the generated palette wins (see the rm in qs-theme). +{ + config, + pkgs, + ... +}: +{ + home.packages = [ pkgs.matugen ]; + + # Default matugen config — renders our two templates on every run. + xdg.configFile."matugen/config.toml".text = '' + [config] + + [templates.quick-shell] + input_path = "${config.home.homeDirectory}/.config/matugen/themes/theme.json.template" + output_path = "${config.home.homeDirectory}/.cache/quickshell/theme.json" + + [templates.kitty] + input_path = "${config.home.homeDirectory}/.config/matugen/themes/kitty-colors.conf.template" + output_path = "${config.home.homeDirectory}/.config/kitty/colors.conf" + ''; + + # Template sources live in the repo so matugen can read them at runtime. + xdg.configFile."matugen/themes/theme.json.template".text = + builtins.readFile ./matugen-themes/theme.json.template; + xdg.configFile."matugen/themes/kitty-colors.conf.template".text = + builtins.readFile ./matugen-themes/kitty-colors.conf.template; + + # qs-theme — regenerate the wallpaper palette, then repaint the live bar. + home.file.".local/bin/qs-theme" = { + executable = true; + text = '' + #!/bin/sh + # Regenerate the Material-You palette (QuickShell bar + kitty) from a + # wallpaper. The bar picks it up live via its theme.json watcher. + set -e + + if [ "$#" -lt 1 ]; then + echo "usage: qs-theme " >&2 + exit 1 + fi + + # matugen refuses to overwrite the Nix-declared symlink; unlink it first + # (home-manager restores it on the next rebuild). + rm -f "$HOME/.config/kitty/colors.conf" + + exec ${pkgs.matugen}/bin/matugen image "$1" -m dark --prefer darkness + ''; + }; +} diff --git a/home-manager/modules/nix-lsp.nix b/home-manager/modules/nix-lsp.nix new file mode 100644 index 0000000..991077c --- /dev/null +++ b/home-manager/modules/nix-lsp.nix @@ -0,0 +1,31 @@ +{ pkgs, ... }: + +{ + # nil (Nix language server) for x1carbon only — wired into VS Code via the + # nix-community Nix IDE extension and into opencode (see opencode.nix). + + home.packages = [ pkgs.nil ]; + + programs.vscode = { + enable = true; + profiles.default = { + extensions = [ pkgs.vscode-extensions.jnoortheen.nix-ide ]; + userSettings = { + "nix.enableLanguageServer" = true; + "nix.serverPath" = "${pkgs.nil}/bin/nil"; + # All nil LSP settings nest under a "nil" key. Formatting shells out + # to nixfmt (the flake formatter), reading stdin / writing stdout. + "nix.serverSettings" = { + nil = { + formatting = { + command = [ + "nixfmt" + "-" + ]; + }; + }; + }; + }; + }; + }; +} diff --git a/home-manager/modules/opencode.nix b/home-manager/modules/opencode.nix new file mode 100644 index 0000000..573cf27 --- /dev/null +++ b/home-manager/modules/opencode.nix @@ -0,0 +1,80 @@ +{ pkgs, ... }: + +{ + # mcp-nixos gives opencode + VS Code real NixOS package/option data instead of + # hallucinated package names. Only installed on x1carbon (this module is only + # imported from hosts/x1carbon), so it does not leak to other machines. + home.packages = [ + pkgs.mcp-nixos + # Gitea MCP server: browse/manage Gitea repos, issues, PRs from opencode. + pkgs.gitea-mcp-server + ]; + + xdg.configFile."opencode/opencode.json".text = builtins.toJSON { + "$schema" = "https://opencode.ai/config.json"; + provider = { + # lmstudio = { + # npm = "@ai-sdk/openai-compatible"; + # name = "LM Studio (local)"; + # options = { + # baseURL = "http://127.0.0.1:1234/v1"; + # }; + # models = { + # "google/gemma-4-e4b" = { + # name = "Gemma 4 E4B (LM Studio)"; + # limit = { + # context = 65536; + # output = 32768; + # }; + # }; + # }; + # }; + opencode-go = { }; + }; + model = "opencode-go/kimi-k2.6"; + lsp = { + # nil (Nix language server, installed by nix-lsp.nix) as a custom LSP + # server so the agent gets Nix diagnostics. Formatter: nixfmt (stdin). + nil = { + command = [ "${pkgs.nil}/bin/nil" ]; + extensions = [ ".nix" ]; + initialization = { + nil = { + formatting = { + command = [ + "nixfmt" + "-" + ]; + }; + }; + }; + }; + }; + mcp = { + # NixOS packages/options MCP server (stdlib CLI; binary provided by + # pkgs.mcp-nixos). Use "the nix tool" to look up real package names, + # options and versions while editing flake/host configs. + nixos = { + type = "local"; + command = [ "mcp-nixos" ]; + enabled = true; + }; + # Gitea MCP server for the self-hosted instance (gitea.edley.me). + # Token comes from the SOPS secret via $GITEA_ACCESS_TOKEN (exported in + # zsh.nix) so it is never committed to the repo. + gitea = { + type = "local"; + command = [ + "gitea-mcp" + "-t" + "stdio" + "-H" + "https://gitea.edley.me" + "-T" + "{env:GITEA_ACCESS_TOKEN}" + ]; + enabled = true; + }; + }; + }; +} diff --git a/home-manager/modules/palette.nix b/home-manager/modules/palette.nix new file mode 100644 index 0000000..bf7ab20 --- /dev/null +++ b/home-manager/modules/palette.nix @@ -0,0 +1,89 @@ +# Shared color palette for the "sci-fi dark glass + vivid neon" desktop. +# Single source of truth: every consumer (QuickShell QML theme, kitty, +# wvkbd, wofi, hyprlock, Hyprland borders/shadows, matugen fallback) reads +# its colors from here instead of hardcoding hex literals. +# +# Colors are stored as raw hex WITHOUT the leading '#'. Use the helpers below +# to format for each consumer: +# palette.hash "#rrggbb" (QML, kitty, wofi borders) +# palette.cssRgba "rgba(r, g, b, a)" (wofi) +# palette.lockRgba "rgba(r, g, b, a)" (hyprlock) +# palette.hyprRgba "rgba(rrggbbaa)" (Hyprland, compact hex+alpha) +# palette.rgb [ r g b ] decimals +rec { + # ---- Surfaces (translucent over the Hyprland layer blur) ---- + # glass/glassPanel are 8-digit AARRGGBB for QML only. + glass = "8c0f111c"; + glassPanel = "c2141627"; + surface = "24283b"; + panel = "141627"; # wofi / hyprlock solid panel + line = "394b70"; + + # ---- Text ---- + text = "c0caf5"; + muted = "a9b1d6"; + ink = "0b0e14"; + + # ---- Neon accents ---- + neon = "00e5ff"; + magenta = "ff2e97"; + violet = "7c4dff"; + cyan = "7dcfff"; + blue = "7aa2f7"; + green = "9ece6a"; + yellow = "e0af68"; + purple = "bb9af7"; + teal = "73daca"; # kitty url_color + danger = "f7768e"; + + # ---- kitty extras (Tokyo Night inspired) ---- + bgDark = "1a1b26"; + color0 = "15161e"; + color8 = "414868"; + selection = "33467c"; + tabBg = "292e42"; + tabFg = "545c7e"; + tabFgBright = "16161e"; + + # ---- wvkbd ---- + wvkbdBg = "1b1e2d"; + wvkbdFgSp = "1f2740"; + + # ---- helpers ---- + hexToInt = + h: + let + m = { + "0" = 0; + "1" = 1; + "2" = 2; + "3" = 3; + "4" = 4; + "5" = 5; + "6" = 6; + "7" = 7; + "8" = 8; + "9" = 9; + a = 10; + b = 11; + c = 12; + d = 13; + e = 14; + f = 15; + }; + len = builtins.stringLength h; + chars = builtins.genList (i: builtins.substring i 1 h) len; + in + builtins.foldl' (acc: c: acc * 16 + m.${c}) 0 chars; + + rgb = h: [ + (hexToInt (builtins.substring 0 2 h)) + (hexToInt (builtins.substring 2 2 h)) + (hexToInt (builtins.substring 4 2 h)) + ]; + + hash = c: "#${c}"; + cssRgba = c: a: "rgba(${builtins.concatStringsSep ", " (map toString (rgb c))}, ${a})"; + lockRgba = c: a: "rgba(${builtins.concatStringsSep ", " (map toString (rgb c))}, ${a})"; + hyprRgba = c: a: "rgba(${c}${a})"; +} diff --git a/home-manager/modules/quickshell-apps.nix b/home-manager/modules/quickshell-apps.nix new file mode 100644 index 0000000..11236fd --- /dev/null +++ b/home-manager/modules/quickshell-apps.nix @@ -0,0 +1,73 @@ +{ + config, + pkgs, + lib, + ... +}: + +# QuickShell autostart app manager: lets the user pick from the gear +# quick-settings panel which installed apps start at login. The candidate +# *pool* is declared here (services.quickshell-apps.apps) and seeded into +# ~/.config/quickshell/autostartseed.json; runtime on/off state and any apps +# added from the UI live in ~/.cache/quickshell/autostart.json. The Hyprland +# login autostart runs `qs-apps run` (see home-manager/modules/hyprland.nix) +# to launch whatever is enabled. +{ + options.services.quickshell-apps = { + enable = lib.mkEnableOption "QuickShell autostart app manager (runtime toggle from the gear panel)"; + + apps = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + name = lib.mkOption { + type = lib.types.str; + description = "Display name shown in the gear panel."; + }; + cmd = lib.mkOption { + type = lib.types.str; + description = "Command to launch the app (shell-style, argv 0 must name the binary)."; + }; + }; + } + ); + default = [ ]; + description = "Candidate pool the user can toggle/start from the shell."; + }; + }; + + config = lib.mkIf config.services.quickshell-apps.enable { + # Store the manager in the Nix store with the procps tools baked in (the + # Hyprland login autostart PATH is not guaranteed to have pgrep/pkill). + xdg.configFile."quickshell/qs-apps.py".text = + builtins.replaceStrings + [ + "@pgrep@" + "@pkill@" + ] + [ + "${pkgs.procps}/bin/pgrep" + "${pkgs.procps}/bin/pkill" + ] + (builtins.readFile ./quickshell-apps.py); + + # Nix-declared pool. The runtime state file is authoritative for on/off + # and for UI-added apps; the manager merges this in on every load, adding + # new entries as disabled. + xdg.configFile."quickshell/autostartseed.json".text = builtins.toJSON ( + map (app: { + name = app.name; + cmd = app.cmd; + }) config.services.quickshell-apps.apps + ); + + home.file.".local/bin/qs-apps" = { + executable = true; + text = '' + #!/bin/sh + exec ${pkgs.python3}/bin/python3 \ + ${config.home.homeDirectory}/.config/quickshell/qs-apps.py "$@" + ''; + }; + }; +} diff --git a/home-manager/modules/quickshell-apps.py b/home-manager/modules/quickshell-apps.py new file mode 100644 index 0000000..88fff4b --- /dev/null +++ b/home-manager/modules/quickshell-apps.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""QuickShell autostart app manager. + +Lets the user pick which installed apps start at login, toggled live from the +gear quick-settings panel (no rebuild needed for preference changes). + +Model +----- +The *pool* of candidates is declared in Nix (host config) and written to +~/.config/quickshell/autostartseed.json. The runtime *state* lives in +~/.cache/quickshell/autostart.json and is authoritative for on/off and for +apps the user adds from the UI (source = "user"). On every `list`/`run` the +seed is merged in: new seed entries are added disabled and, for existing seed +entries, the Nix command is re-applied while keeping the enabled flag. A +user-added app named like a seed entry wins (the seed never re-enables it). + +Commands +-------- + list -> JSON {apps: [{name, cmd, enabled, source}]} (after merge) + toggle -> flip enabled; start/stop the app now, then persist + add -> create a user entry (enabled, starts now); if the name + already exists, just enable it + remove -> stop (best-effort) and forget the entry + run -> login autostart: launch every enabled app (no dupes) +""" + +import json +import os +import shlex +import subprocess +import sys + +HOME = os.path.expanduser("~") +CACHE_DIR = os.path.join(HOME, ".cache", "quickshell") +STATE_FILE = os.path.join(CACHE_DIR, "autostart.json") +SEED_FILE = os.path.join(HOME, ".config", "quickshell", "autostartseed.json") + +PGREP = "/run/current-system/sw/bin/pgrep" +PKILL = "/run/current-system/sw/bin/pkill" + + +def ensure_cache_dir(): + os.makedirs(CACHE_DIR, exist_ok=True) + + +def load_state(): + try: + with open(STATE_FILE) as fh: + data = json.load(fh) + except (OSError, ValueError): + data = {"apps": []} + if not isinstance(data, dict) or not isinstance(data.get("apps"), list): + data = {"apps": []} + return data + + +def save_state(data): + ensure_cache_dir() + with open(STATE_FILE, "w") as fh: + json.dump(data, fh, indent=2) + + +def load_seed(): + try: + with open(SEED_FILE) as fh: + seed = json.load(fh) + except (OSError, ValueError): + return [] + if not isinstance(seed, list): + return [] + out = [] + for entry in seed: + if isinstance(entry, dict) and entry.get("name") and entry.get("cmd"): + out.append({"name": str(entry["name"]), "cmd": str(entry["cmd"])}) + return out + + +def merge_seed(data): + """Apply the Nix-declared pool to the runtime state (add-missing only).""" + seed = load_seed() + for s in seed: + found = next( + (a for a in data["apps"] if a.get("name", "").lower() == s["name"].lower()), + None, + ) + if found is None: + data["apps"].append( + {"name": s["name"], "cmd": s["cmd"], "enabled": False, "source": "seed"} + ) + elif found.get("source") == "seed": + found["cmd"] = s["cmd"] + return data + + +def find_app(data, name): + return next( + (a for a in data["apps"] if a.get("name", "").lower() == name.lower()), None + ) + + +def binary_of(cmd): + try: + toks = shlex.split(cmd) + except ValueError: + return None + return os.path.basename(toks[0]) if toks else None + + +def pattern_of(cmd): + """pgrep/pkill -f pattern for the app's executable. + + Electron apps (element-desktop, bitwarden-desktop) run with comm=electron, + so comm (-x) matching misses them and we match the full command line + instead. The bracket trick 'e[e]lement-desktop' still matches + element-desktop but never the pgrep/pkill command itself (its own cmdline + contains the literal bracketed form) — avoiding the classic -f self-match. + """ + binary = binary_of(cmd) + if not binary: + return None + if len(binary) < 2: + return binary + return binary[:1] + "[" + binary[1:2] + "]" + binary[2:] + + +def _self_and_ancestors(): + """PIDs of this process and its parent chain. + + The -f guard can be fooled by the *invoking* process: `qs-apps add X "cmd"` + passes the command as an argv element, so our own cmdline (and ancestors + like a `sh -c 'qs-apps add X cmd'` wrapper) contains the app's binary name + and pgrep -f would match it instead of the real app. Excluding the whole + ancestor chain makes the guard see only genuinely running apps. + """ + pids = {os.getpid()} + ppid = os.getppid() + seen = set() + while ppid > 1 and ppid not in seen: + seen.add(ppid) + pids.add(ppid) + try: + with open("/proc/%d/stat" % ppid) as fh: + parts = fh.read().split() + ppid = int(parts[3]) + except (OSError, IndexError, ValueError): + break + return pids + + +def is_running(pattern): + if not pattern: + return False + try: + r = subprocess.run([PGREP, "-f", pattern], capture_output=True, text=True) + except OSError: + return False + if r.returncode != 0: + return False + self_pids = _self_and_ancestors() + for line in r.stdout.splitlines(): + try: + pid = int(line.strip()) + except ValueError: + continue + if pid not in self_pids: + return True + return False + + +def start_app(cmd): + """Launch cmd detached. Returns True if it is (or already is) running.""" + try: + toks = shlex.split(cmd) + except ValueError: + return False + if not toks: + return False + pattern = pattern_of(cmd) + if pattern and is_running(pattern): + return True + try: + subprocess.Popen( + toks, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + return True + except OSError: + return False + + +def stop_app(cmd): + """Best-effort kill by -f pattern (comm is 'electron' for Electron apps).""" + pattern = pattern_of(cmd) + if not pattern: + return + try: + subprocess.run([PKILL, "-f", pattern], capture_output=True) + except OSError: + pass + + +def cmd_toggle(name): + data = merge_seed(load_state()) + app = find_app(data, name) + if app is None: + return {"ok": False, "error": "no app named " + name} + app["enabled"] = not app["enabled"] + if app["enabled"]: + started = start_app(app["cmd"]) + else: + stop_app(app["cmd"]) + started = True + save_state(data) + return {"ok": started, "enabled": app["enabled"]} + + +def cmd_add(name, cmd): + data = merge_seed(load_state()) + app = find_app(data, name) + if app is None: + data["apps"].append( + {"name": name, "cmd": cmd, "enabled": True, "source": "user"} + ) + app = data["apps"][-1] + else: + app["enabled"] = True + if app.get("source") == "user": + app["cmd"] = cmd + started = start_app(app["cmd"]) + save_state(data) + return {"ok": started} + + +def cmd_remove(name): + data = merge_seed(load_state()) + app = find_app(data, name) + if app is None: + return {"ok": True} + stop_app(app["cmd"]) + data["apps"] = [a for a in data["apps"] if a is not app] + save_state(data) + return {"ok": True} + + +def cmd_run(): + data = merge_seed(load_state()) + for app in data["apps"]: + if app.get("enabled"): + start_app(app["cmd"]) + save_state(data) + return {"ok": True} + + +def cmd_list(): + data = merge_seed(load_state()) + save_state(data) + return {"apps": data["apps"]} + + +def main(): + args = sys.argv[1:] + cmd = args[0] if args else "list" + + if cmd == "list": + out = cmd_list() + print(json.dumps(out)) + elif cmd == "run": + print(json.dumps(cmd_run())) + elif cmd == "toggle": + name = args[1] if len(args) > 1 else "" + print(json.dumps(cmd_toggle(name))) + elif cmd == "add": + name = args[1] if len(args) > 1 else "" + command = args[2] if len(args) > 2 else "" + print(json.dumps(cmd_add(name, command))) + elif cmd == "remove": + name = args[1] if len(args) > 1 else "" + print(json.dumps(cmd_remove(name))) + else: + print(json.dumps({"ok": False, "error": "unknown command: " + cmd})) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/home-manager/modules/quickshell-bt.py b/home-manager/modules/quickshell-bt.py new file mode 100644 index 0000000..b0666fc --- /dev/null +++ b/home-manager/modules/quickshell-bt.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""QuickShell bluetooth manager backend. + +Talks to bluez via `bluetoothctl` (text/shell parsing — the tool has no JSON +mode). State is read live from the adapter (`bluetoothctl show`) and per device +(`bluetoothctl info `), so it reflects reality even after pairing from +another app. The `qs-bt` wrapper (home-manager hyprland module) passes the +absolute bluetoothctl path in $BLUECTL. + +Commands +-------- + status -> JSON adapter + devices + {powered,name,alias,pairable,discoverable, + discovering,devices:[{mac,name,icon,paired, + trusted,connected,battery}]} + power on|off -> toggle adapter power -> JSON ok + scan -> discovery for SCAN_SECONDS, then stop; JSON + list of {mac,name} found during the scan + connect / disconnect + pair -> pair (bounded; pairing prompts end it) + trust / untrust + remove -> unpair / forget +All non-status commands return {"ok": bool, "error": "..."}. +""" + +import json +import os +import re +import subprocess +import sys + +BLUECTL = os.environ.get("BLUECTL", "bluetoothctl") +SCAN_SECONDS = 8 +PAIR_TIMEOUT = 30 +CONNECT_TIMEOUT = 12 +WAIT_TIMEOUT = 10 + + +def run(args, timeout=WAIT_TIMEOUT): + try: + p = subprocess.run( + [BLUECTL] + args, + capture_output=True, + text=True, + timeout=timeout, + ) + return p.returncode, p.stdout, p.stderr + except subprocess.TimeoutExpired as exc: + tail = (exc.stdout or exc.stderr or b"") + if isinstance(tail, bytes): + tail = tail.decode(errors="replace") + return -1, tail, "command timed out" + except OSError as exc: + return -2, "", str(exc) + + +def field(line, key): + low = line.lstrip() + return low.split(":", 1)[1].strip() if low.startswith(key + ":") else None + + +def parse_adapter(out): + """Adapter sections from `bluetoothctl show`; prefer the [default] one.""" + sections = [] + cur = None + for line in out.splitlines(): + if line.startswith("Controller "): + bits = line.split() + cur = {"mac": bits[1], "default": "[default]" in line} + sections.append(cur) + elif cur is not None: + v = field(line, "Powered") + if v: + cur["powered"] = v == "yes" + v = field(line, "Discovering") + if v: + cur["discovering"] = v == "yes" + v = field(line, "Pairable") + if v: + cur["pairable"] = v == "yes" + v = field(line, "Discoverable") + if v: + cur["discoverable"] = v == "yes" + v = field(line, "Name") + if v: + cur["name"] = v + v = field(line, "Alias") + if v: + cur["alias"] = v + for s in sections: + if s.get("default"): + return s + return sections[0] if sections else None + + +def parse_device(mac): + rc, out, _ = run(["info", mac], timeout=WAIT_TIMEOUT) + dev = { + "mac": mac, + "name": "", + "icon": "", + "paired": False, + "trusted": False, + "connected": False, + "battery": None, + } + if rc != 0: + return dev + for line in out.splitlines(): + v = field(line, "Name") + if v: + dev["name"] = v + v = field(line, "Icon") + if v: + dev["icon"] = v + v = field(line, "Paired") + if v: + dev["paired"] = v == "yes" + v = field(line, "Trusted") + if v: + dev["trusted"] = v == "yes" + v = field(line, "Connected") + if v: + dev["connected"] = v == "yes" + m = re.search(r"Battery Percentage:\s*0x[0-9A-Fa-f]+\s*\((\d+)\)", line) + if m: + dev["battery"] = int(m.group(1)) + return dev + + +def list_devices(): + rc, out, _ = run(["devices"], timeout=WAIT_TIMEOUT) + macs = [] + if rc == 0: + for line in out.splitlines(): + bits = line.split() + if len(bits) >= 2 and bits[0] == "Device": + mac = bits[1] + if mac not in macs: + macs.append(mac) + return [parse_device(m) for m in macs] + + +def status(): + rc, out, _ = run(["show"], timeout=WAIT_TIMEOUT) + adapter = parse_adapter(out) if rc == 0 else None + devs = list_devices() + # connected first, then alphabetical — the stable order the popup renders. + devs.sort(key=lambda d: (not d["connected"], (d["name"] or d["mac"]).lower())) + return { + "powered": bool(adapter and adapter.get("powered")), + "name": adapter.get("name") if adapter else "", + "alias": adapter.get("alias") if adapter else "", + "pairable": bool(adapter and adapter.get("pairable")), + "discoverable": bool(adapter and adapter.get("discoverable")), + "discovering": bool(adapter and adapter.get("discovering")), + "devices": devs, + } + + +def scan(): + """Discovery for SCAN_SECONDS; bluetoothctl parses NEW/CHG device lines.""" + rc, out, _ = run( + ["--timeout", str(SCAN_SECONDS), "scan", "on"], + timeout=SCAN_SECONDS + 8, + ) + found = {} + for line in out.splitlines(): + if line.startswith("[NEW] Device"): + mac, _, name = line.split()[2:5] + found[mac] = name + elif line.startswith("[CHG] Device"): + bits = line.split() + if len(bits) >= 5 and bits[3] == "Name:": + found[bits[2]] = line.split("Name:", 1)[1].strip() + return [{"mac": m, "name": n} for m, n in sorted(found.items())] + + +def action(args): + rc, out, err = run(args, timeout=PAIR_TIMEOUT if args and args[0] == "pair" else CONNECT_TIMEOUT) + if rc == 0: + return {"ok": True, "error": ""} + # bluetoothctl prints failures like "Failed to connect: ..." to stdout. + detail = "" + for line in (out or "").splitlines(): + if "Failed" in line or "not" in line.lower() or "error" in line.lower(): + detail = line.strip() + break + return {"ok": False, "error": detail or (err or "command failed")} + + +def main(): + args = sys.argv[1:] + cmd = args[0] if args else "status" + if cmd == "status": + print(json.dumps(status())) + elif cmd == "power": + val = args[1] if len(args) > 1 else "" + print(json.dumps(action(["power", val]) if val in ("on", "off") else {"ok": False, "error": "power on|off"})) + elif cmd == "scan": + print(json.dumps(scan())) + elif cmd in ("pair", "connect", "disconnect", "trust", "untrust", "remove"): + mac = args[1] if len(args) > 1 else "" + if not mac: + print(json.dumps({"ok": False, "error": "missing device address"})) + else: + print(json.dumps(action([cmd, mac]))) + else: + print(json.dumps({"ok": False, "error": "unknown command: " + cmd})) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/home-manager/modules/quickshell-cal.nix b/home-manager/modules/quickshell-cal.nix new file mode 100644 index 0000000..e355f75 --- /dev/null +++ b/home-manager/modules/quickshell-cal.nix @@ -0,0 +1,136 @@ +{ + config, + pkgs, + lib, + ... +}: + +let + # Python with the ICS parsing stack baked in: icalendar + recurring + # recurrence expansion for the reminder scanner. + python = pkgs.python3.withPackages (ps: [ + ps.icalendar + ps.recurring-ical-events + ]); +in + +{ + options.services.quickshell-cal = { + enable = lib.mkEnableOption "QuickShell calendar + weather sync (Nextcloud CalDAV via vdirsyncer/khal, Open-Meteo)"; + + eventDays = lib.mkOption { + type = lib.types.int; + default = 31; + description = "How many days ahead of today khal should expand/return events for the shell popup."; + }; + }; + + config = lib.mkIf config.services.quickshell-cal.enable { + # The one Nextcloud fact the sync pipeline needs beyond the secret: the + # base user-principal CalDAV URL (vdirsyncer discovers every calendar + # under it). The username + password live in the SOPS secret + # "hp-laptop/nextcloud-cal-env". + home.packages = [ + python + pkgs.vdirsyncer + pkgs.pipewire + pkgs.libnotify + ]; + + # ---------- sync script ---------- + # Store the python in the Nix store with tool paths baked in (user units + # have an unpredictable PATH), then expose it on the QuickShell PATH. + # Substitutions keep the source tree readable. + xdg.configFile."quickshell-cal/qs-cal-sync.py".text = + builtins.replaceStrings + [ + "@vdirsyncer@" + "@libnotify@" + "@pipewire@" + ] + [ + "${pkgs.vdirsyncer}" + "${pkgs.libnotify}" + "${pkgs.pipewire}" + ] + (builtins.readFile ./quickshell-cal.py); + + home.file.".local/bin/qs-cal-sync" = { + executable = true; + text = '' + #!/bin/sh + exec ${python}/bin/python3 \ + ${config.home.homeDirectory}/.config/quickshell-cal/qs-cal-sync.py "$@" + ''; + }; + + # ---------- khal config ---------- + # Generated at runtime by the sync script from the vdirsyncer discovery: + # one [[calendar]] section per subdir of ~/.local/share/vdirsyncer (khal + # cannot glob, and the calendar set only changes when Nextcloud does). + # Removing this file from HM keeps ~/.config/khal/config a plain user + # file the script may rewrite on every sync. + + # ---------- periodic sync ---------- + # Runs as the user so caches are written to ~/.cache without privilege + # games, and the SOPS env file is readable because the host config sets + # owner petere / group users / mode 0440 on it. + systemd.user.services.qs-cal-sync = { + Unit = { + Description = "Sync Nextcloud calendar + weather for the QuickShell popup"; + After = [ "network-online.target" ]; + }; + Service = { + Type = "oneshot"; + ExecStart = "${config.home.homeDirectory}/.local/bin/qs-cal-sync sync"; + }; + }; + + systemd.user.timers.qs-cal-sync = { + Unit = { + Description = "Periodic Nextcloud calendar + weather sync"; + After = [ "network-online.target" ]; + }; + Install = { + WantedBy = [ "timers.target" ]; + }; + Timer = { + OnBootSec = "1min"; + OnUnitActiveSec = "20min"; + Persistent = true; + }; + }; + + # ---------- reminders ---------- + # Fires VALARM due-tones once per minute: plays the cached chime and + # sends a desktop notification that lands in the QuickShell notification + # center. Reads only the local .ics stores (no network), so it is cheap + # enough to run every 60s. + systemd.user.services.qs-cal-remind = { + Unit = { + Description = "Fire QuickShell calendar reminders"; + }; + Service = { + Type = "oneshot"; + ExecStart = "${config.home.homeDirectory}/.local/bin/qs-cal-sync remind"; + }; + }; + + systemd.user.timers.qs-cal-remind = { + Unit = { + Description = "Per-minute QuickShell calendar reminder check"; + }; + Install = { + WantedBy = [ "timers.target" ]; + }; + Timer = { + OnBootSec = "1min"; + OnUnitActiveSec = "30s"; + # default AccuracySec is a whole minute, which (plus polling phase) + # lets alerts land up to ~2min late; 1s keeps current-skew tiny. + AccuracySec = "1s"; + Persistent = true; + }; + }; + }; +} diff --git a/home-manager/modules/quickshell-cal.py b/home-manager/modules/quickshell-cal.py new file mode 100644 index 0000000..4cb53fa --- /dev/null +++ b/home-manager/modules/quickshell-cal.py @@ -0,0 +1,1000 @@ +#!/usr/bin/env python3 +# quickshell-cal.py — calendar + weather backend for the QuickShell bar popup. +# +# Runtime architecture: +# * A home-manager user systemd timer runs `qs-cal-sync sync` every 20 min: +# - Nextcloud CalDAV -> vdirsyncer -> khal (recurrence-aware) -> events.json +# - Open-Meteo forecast (keyless) -> weather.json +# * The QuickShell popup calls `qs-cal-sync read` (instant, merges caches to +# stdout as a single JSON doc) whenever it opens, and `qs-cal-sync +# setloc ""` from the in-popup location editor. +# +# Nextcloud credentials are NOT compiled into anything: the sync step reads the +# env file rendered from the SOPS secret (default /run/secrets/hp-laptop/ +# nextcloud-cal-env; override with $QS_CAL_SECRET_ENV), writes a temporary +# vdirsyncer config with them (mode 0600), syncs, then deletes it again. +# +# Every write is atomic (tmp + rename) so the shell never reads a half file. + +import datetime +import json +import math +import os +import re +import subprocess +import sys +import tempfile +import uuid +import wave +import urllib.parse +import urllib.request + +CACHE = os.path.expanduser("~/.cache/quickshell-cal") +SECRET_ENV = os.environ.get("QS_CAL_SECRET_ENV", "/run/secrets/hp-laptop/nextcloud-cal-env") + +# Paths are substituted at build time by the Nix home-manager module so the +# script keeps working regardless of what PATH looks like in a user unit. +VDIRSYNCER = "@vdirsyncer@/bin/vdirsyncer" + +VDIR = os.path.expanduser("~/.local/share/vdirsyncer") +STATUS = os.path.expanduser("~/.local/state/vdirsyncer") + +EVENTS_FILE = os.path.join(CACHE, "events.json") +WEATHER_FILE = os.path.join(CACHE, "weather.json") +LOC_FILE = os.path.join(CACHE, "loc.json") +# Last-sync health, surfaced as the header badge in the shell popup. +SYNC_FILE = os.path.join(CACHE, "sync.json") +TMP_CONF = os.path.join(CACHE, "vdirsyncer.conf") + +# Reminder plumbing (paths baked in by the Nix module). notify-send talks to +# the QuickShell NotificationServer (org.freedesktop.Notifications) so toasts +# appear in our own notification center. +NOTIFY = "@libnotify@/bin/notify-send" +PW_PLAY = "@pipewire@/bin/pw-play" +CHIME_FILE = os.path.join(CACHE, "chime.wav") +# Remember which (uid, occurrence, trigger) we already fired so the per-minute +# remind timer never double-fires. +REMINDER_STATE = os.path.join(CACHE, "reminders-fired.json") +# "uid|occurrence|trigger" -> ISO time at which a snoozed reminder may fire +# again. The shell writes it via `qs-cal-sync snooze `; remind() +# skips anything whose deadline is still in the future. +SNOOZE_FILE = os.path.join(CACHE, "snoozes.json") + +# First-run bootstrap location (never leaves the machine; change in the popup). +DEFAULT_LOC = {"name": "Edinburgh", "lat": "55.9533", "lon": "-3.1883"} + +EVENT_DAYS = 31 + +UA = "Nix-Vibe-quickshell/1.0" + + +def log(msg): + sys.stderr.write("[qs-cal] %s\n" % msg) + sys.stderr.flush() + + +def rd(blob, fallback): + """json.load a file, tolerating absence/corruption.""" + try: + with open(blob, "r") as f: + return json.load(f) + except (OSError, ValueError): + return fallback + + +def wr(blob, obj): + """Atomic json write.""" + os.makedirs(CACHE, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=CACHE, suffix=".tmp") + with os.fdopen(fd, "w") as f: + json.dump(obj, f, ensure_ascii=False) + os.replace(tmp, blob) + + +def http_get(url, timeout=10): + req = urllib.request.Request(url, headers={"User-Agent": UA}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return r.read().decode("utf-8", "replace") + + +def parse_env_file(path): + """Read a sops-rendered KEY=VALUE env file into a dict.""" + if not os.path.exists(path): + return {} + out = {} + try: + with open(path, "r") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + k, _, v = line.partition("=") + if k: + out[k.strip()] = v.strip().strip('"').strip("'") + except OSError as e: + log("cannot read secret env %s: %s" % (path, e)) + return {} + return out + + +def write_tmp_vdirsyncer_conf(env): + """Temporary vdirsyncer config that syncs EVERY calendar under the CalDAV + principal (collections = ["from a", "from b"] -> discovery).""" + os.makedirs(os.path.dirname(TMP_CONF), exist_ok=True) + url = env.get("NEXTCLOUD_CALDAV_URL", "") + user = env.get("NEXTCLOUD_CALDAV_USERNAME", "") + pw = env.get("NEXTCLOUD_CALDAV_PASSWORD", "") + # Nextcloud CalDAV requires the username in the URL path. If the user + # supplies the base path (/remote.php/dav/calendars/), append the username. + if url and user and url.rstrip("/").endswith("/calendars"): + url = url.rstrip("/") + "/" + user + conf = "\n".join([ + "[general]", + 'status_path = "%s"' % STATUS, + "", + "[pair nextcloud]", + 'a = "nextcloud_local"', + 'b = "nextcloud_remote"', + 'collections = ["from a", "from b"]', + "", + "[storage nextcloud_local]", + 'type = "filesystem"', + 'path = "%s"' % VDIR, + 'fileext = ".ics"', + "", + "[storage nextcloud_remote]", + 'type = "caldav"', + 'url = "%s"' % url, + 'username = "%s"' % user, + 'password = "%s"' % pw, + "", + ]) + with open(TMP_CONF, "w") as f: + f.write(conf) + os.chmod(TMP_CONF, 0o600) + + +def run_sync_cmd(argv, timeout=180, stdin_input=None): + try: + p = subprocess.run( + argv, input=stdin_input, capture_output=True, text=True, timeout=timeout) + if p.returncode != 0: + log("failed: %s -> %s" % (" ".join(argv), (p.stderr or p.stdout)[:400])) + return False + return True + except (OSError, subprocess.TimeoutExpired) as e: + log("error running %s: %s" % (argv[0], e)) + return False + + +def list_calendars(): + """Names of the calendars vdirsyncer discovered (one subdir per calendar).""" + if not os.path.isdir(VDIR): + return [] + try: + return sorted(n for n in os.listdir(VDIR) + if os.path.isdir(os.path.join(VDIR, n))) + except OSError: + return [] + + +def ics_escape(s): + """RFC 5545 TEXT escaping (backslash, comma, semicolon, newline).""" + return (str(s).replace("\\", "\\\\") + .replace(";", "\\;") + .replace(",", "\\,") + .replace("\n", "\\n")) + + +# Repeat choices offered by the shell form -> RFC 5545 FREQ. +REPEAT_FREQ = {"DAILY": "DAILY", "WEEKLY": "WEEKLY", "MONTHLY": "MONTHLY"} + + +def reminder_minutes(vevent): + """Minutes-before from the first relative TRIGGER VALARM, or 0 when the + event has none (or only absolute/dated triggers).""" + for arm in vevent.walk("VALARM"): + try: + trig = arm.get("TRIGGER") + if trig is None: + continue + delta = trig.dt + except Exception: + continue + if not isinstance(delta, datetime.timedelta): + continue + # -PT5M -> 5 (minutes before start) + m = re.fullmatch(r"-PT(\d+)M", str(trig)) + if m: + return int(m.group(1)) + return 0 + + +def vdir_push(): + """Push local .ics changes up to Nextcloud (vdirsyncer sync). No-op when + the secret env file is missing.""" + secret = parse_env_file(SECRET_ENV) + if not secret.get("NEXTCLOUD_CALDAV_URL"): + return True + write_tmp_vdirsyncer_conf(secret) + try: + y = ("y\n") * 50 + return run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "sync"], stdin_input=y) + finally: + if os.path.exists(TMP_CONF): + os.remove(TMP_CONF) + + +def add_event(params): + """Write a VEVENT into the chosen calendar's vdirsyncer local dir, then + push it to Nextcloud. params is the JSON dict from the shell popup.""" + cal = (params.get("cal") or "").strip() + title = (params.get("title") or "").strip() + if not cal or not title: + return {"ok": False, "error": "calendar and title are required"} + caldir = os.path.join(VDIR, cal) + if not os.path.isdir(caldir): + return {"ok": False, "error": "unknown calendar %r" % cal} + + date = (params.get("date") or "").strip() + all_day = bool(params.get("allDay")) + start = (params.get("start") or "").strip() + end = (params.get("end") or "").strip() + loc = (params.get("loc") or "").strip() + reminder = int(params.get("reminder") or 0) + repeat = (params.get("repeat") or "NONE").strip().upper() + + tzlocal = datetime.datetime.now().astimezone().tzinfo + uid = "%s@nix-vibe" % uuid.uuid4() + now = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + # normalise dates/times -> ISO parts + try: + dt = datetime.date.fromisoformat(date) + except ValueError: + return {"ok": False, "error": "invalid date %r" % date} + + def dt_line(key, d): + return d.astimezone(datetime.timezone.utc).strftime(key + ":%Y%m%dT%H%M%SZ") + + lines = ["BEGIN:VCALENDAR", "VERSION:2.0", + "PRODID:-//Nix-Vibe//quickshell//EN", + "BEGIN:VEVENT", "UID:" + uid, "DTSTAMP:" + now] + + if all_day: + # RFC 5545 all-day events use VALUE=DATE and exclusive end date. + lines.append("DTSTART;VALUE=DATE:%s" % dt.strftime("%Y%m%d")) + endd = dt + datetime.timedelta(days=1) + lines.append("DTEND;VALUE=DATE:%s" % endd.strftime("%Y%m%d")) + else: + start_dt = datetime.datetime.strptime(start or "09:00", "%H:%M").replace( + year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal) + end_dt = datetime.datetime.strptime(end or "10:00", "%H:%M").replace( + year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal) + if end_dt <= start_dt: + end_dt += datetime.timedelta(days=1) + lines.append(dt_line("DTSTART", start_dt)) + lines.append(dt_line("DTEND", end_dt)) + + if repeat in REPEAT_FREQ: + lines.append("RRULE:FREQ=%s" % REPEAT_FREQ[repeat]) + lines += ["SUMMARY:" + ics_escape(title)] + if loc: + lines.append("LOCATION:" + ics_escape(loc)) + if reminder and reminder > 0: + lines += _alarm_lines(title, reminder) + lines += ["END:VEVENT", "END:VCALENDAR", ""] + vcal = "\r\n".join(lines) + + path = os.path.join(caldir, uid + ".ics") + try: + with open(path, "w") as f: + f.write(vcal) + except OSError as e: + return {"ok": False, "error": str(e)} + + if not vdir_push(): + log("add: push failed for %s" % title) + _mark_sync_error("push failed for %r" % title) + fetch_events() + return {"ok": True, "uid": uid, "file": os.path.basename(path), + "cal": cal, "title": title} + + +def _alarm_lines(title, reminder): + return [ + "BEGIN:VALARM", + "ACTION:DISPLAY", + "TRIGGER:-PT%dM" % reminder, + "DESCRIPTION:" + ics_escape(title), + "END:VALARM", + ] + + +def _event_file(cal, file): + """Resolve a (cal, basename/path) pair from the shell back to an existing + .ics on disk. Returns None when it has vanished (deleted server-side).""" + if not cal: + return None + base = os.path.basename(str(file)) + cand = os.path.join(VDIR, cal, base) + if base and base.endswith(".ics") and os.path.isfile(cand): + return cand + return None + + +def edit_event(params): + """Rewrite a VEVENT that the shell already knows about (matched by uid), + then push + refetch. The whole series is edited for recurring events.""" + import icalendar + + uid = (params.get("uid") or "").strip() + file = _event_file(params.get("cal"), params.get("file")) + title = (params.get("title") or "").strip() + if not uid or not file: + return {"ok": False, "error": "missing event reference"} + if not title: + return {"ok": False, "error": "title is required"} + + date = (params.get("date") or "").strip() + all_day = bool(params.get("allDay")) + start = (params.get("start") or "").strip() + end = (params.get("end") or "").strip() + loc = (params.get("loc") or "").strip() + reminder = int(params.get("reminder") or 0) + repeat = (params.get("repeat") or "NONE").strip().upper() + + try: + dt = datetime.date.fromisoformat(date) + except ValueError: + return {"ok": False, "error": "invalid date %r" % date} + + try: + with open(file, "rb") as f: + cal = icalendar.Calendar.from_ical(f.read().decode("utf-8", "replace")) + except (OSError, ValueError) as e: + log("edit: cannot read %s: %s" % (file, e)) + return {"ok": False, "error": "cannot read event file"} + + vevent = None + for v in cal.walk("VEVENT"): + if str(v.get("UID") or "") == uid: + vevent = v + break + if vevent is None: + return {"ok": False, "error": "event uid no longer on disk (refetch?)"} + + vevent["DTSTAMP"] = datetime.datetime.now(datetime.timezone.utc).strftime( + "%Y%m%dT%H%M%SZ") + vevent.pop("DTSTART", None) + vevent.pop("DTEND", None) + vevent.pop("RRULE", None) + vevent["SUMMARY"] = ics_escape(title) + + if all_day: + # vDDDTypes: a plain date -> DTSTART;VALUE=DATE (exclusive DTEND +1d) + vevent["DTSTART"] = dt + vevent["DTEND"] = dt + datetime.timedelta(days=1) + else: + tzlocal = datetime.datetime.now().astimezone().tzinfo + start_dt = datetime.datetime.strptime(start or "09:00", "%H:%M").replace( + year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal) + end_dt = datetime.datetime.strptime(end or "10:00", "%H:%M").replace( + year=dt.year, month=dt.month, day=dt.day, tzinfo=tzlocal) + if end_dt <= start_dt: + end_dt += datetime.timedelta(days=1) + def ics_dt(d): + return d.astimezone(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + vevent["DTSTART"] = ics_dt(start_dt) + vevent["DTEND"] = ics_dt(end_dt) + + if repeat in REPEAT_FREQ: + vevent["RRULE"] = "FREQ=%s" % REPEAT_FREQ[repeat] + if loc: + vevent["LOCATION"] = ics_escape(loc) + else: + vevent.pop("LOCATION", None) + + # replace alarms entirely (single-child vdirsyncer items usually only hold + # one VEVENT plus optional VTIMEZONE; keep anything non-VALARM intact) + vevent.subcomponents = [ + c for c in vevent.subcomponents if str(c.name) != "VALARM"] + if reminder and reminder > 0: + arm = icalendar.Alarm() + arm.add("ACTION", "DISPLAY") + arm.add("TRIGGER", datetime.timedelta(minutes=-reminder)) + arm.add("DESCRIPTION", title) + vevent.add_component(arm) + + try: + with open(file, "wb") as f: + f.write(cal.to_ical()) + except OSError as e: + return {"ok": False, "error": str(e)} + + if not vdir_push(): + log("edit: push failed for %s" % uid) + _mark_sync_error("push failed for edit (uid %s)" % uid) + fetch_events() + return {"ok": True, "uid": uid, "cal": params.get("cal"), "title": title} + + +def delete_event(params): + """Delete the .ics backing an event, push the removal to Nextcloud and + refetch. Deletes the whole event (every occurrence of a series).""" + file = _event_file(params.get("cal"), params.get("file")) + uid = (params.get("uid") or "").strip() + if not file or not uid: + return {"ok": False, "error": "missing event reference"} + try: + os.remove(file) + except OSError as e: + return {"ok": False, "error": str(e)} + if not vdir_push(): + log("delete: push failed for %s" % uid) + _mark_sync_error("push failed for delete (uid %s)" % uid) + fetch_events() + return {"ok": True, "uid": uid} + + +def ensure_chime(): + """Synthesize a short two-tone 'ping' if we don't have one cached.""" + if os.path.exists(CHIME_FILE): + return CHIME_FILE + os.makedirs(CACHE, exist_ok=True) + rate = 44100 + n = (3.0 * 4) // 8 # make it a short pluck + # 0.55s decayed 880Hz ping + samples = [] + for i in range(int(rate * 0.45)): + t = i / rate + env = math.exp(-t * 6) + samples.append(int(12000 * env * math.sin(2 * math.pi * 880 * t))) + for i in range(int(rate * 0.45)): + t = i / rate + env = math.exp(-t * 6) + samples.append(int(12000 * env * math.sin(2 * math.pi * 1320 * t))) + with wave.open(CHIME_FILE, "wb") as w: + w.setnchannels(1) + w.setsampwidth(2) + w.setframerate(rate) + w.writeframes(b"".join(int(s).to_bytes(2, "little", signed=True) + for s in samples)) + return CHIME_FILE + + +def remind(): + """Scan every .ics in the vdirsyncer dirs for VALARM triggers, expand + RRULE recurrences, and fire any whose reminder time has just arrived: + play the cached chime and send a desktop notification (lands in the + QuickShell notification center). Tracked by (uid, occurrence, trigger) + so the per-minute timer never fires the same reminder twice.""" + import icalendar + import recurring_ical_events + + fired = rd(REMINDER_STATE, {}) + snoozes = rd(SNOOZE_FILE, {}) + now = datetime.datetime.now().astimezone() + fired_any = False + snooze_dirty = False + + for calname in list_calendars(): + caldir = os.path.join(VDIR, calname) + if not os.path.isdir(caldir): + continue + for fn in os.listdir(caldir): + if not fn.endswith(".ics"): + continue + path = os.path.join(caldir, fn) + try: + with open(path, "rb") as f: + data = f.read() + except OSError as e: + log("remind: cannot read %s: %s" % (path, e)) + continue + # fast pre-filter: >90% of files have no VALARM at all, so skip + # the (expensive) full icalendar + recurrence expansion for them. + if b"VALARM" not in data: + continue + try: + cal = icalendar.Calendar.from_ical(data.decode("utf-8", "replace")) + except Exception as e: + log("remind: cannot parse %s: %s" % (path, e)) + continue + + for vevent in cal.walk("VEVENT"): + try: + uid = str(vevent.get("UID") or str(uuid.uuid4())) + summary = str(vevent.get("SUMMARY") or "(no title)") + loc = str(vevent.get("LOCATION") or "") + alarms = vevent.walk("VALARM") + if not alarms: + continue + dtstart = vevent.get("DTSTART") + if dtstart is None: + continue + # run recurrence expansion over a generous window + comp = icalendar.Calendar() + for tz in cal.walk("VTIMEZONE"): + comp.add_component(tz) + comp.add_component(vevent) + occs = recurring_ical_events.of(comp).between( + now - datetime.timedelta(days=1), + now + datetime.timedelta(days=31)) + for occ in occs: + start = occ.get("DTSTART").dt + if isinstance(start, datetime.date) and not isinstance(start, datetime.datetime): + start = datetime.datetime.combine(start, datetime.time.min, tzinfo=now.tzinfo) + if start.tzinfo is None: + start = start.replace(tzinfo=now.tzinfo) + for alarm in alarms: + try: + trig = alarm.get("TRIGGER") + delta = trig.dt + except Exception: + continue + if not isinstance(delta, datetime.timedelta): + continue # absolute triggers skipped (rare) + # TRIGGER:-PT5M means 5 min BEFORE the start, so + # a negative delta moves `when` earlier. + when = start + delta + key = "%s|%s|%s" % (uid, start.isoformat(), delta) + snoozed_until = snoozes.get(key) + refire = False + if snoozed_until: + try: + if datetime.datetime.fromisoformat(snoozed_until) > now: + continue # still snoozing this one + except ValueError: + pass + # deadline passed: fire now even though we're + # outside the usual 2-minute lateness window, + # and let it happen again later. + snoozes.pop(key, None) + snooze_dirty = True + refire = True + if key in fired: + continue + if when <= now and ((now - when) <= datetime.timedelta(minutes=2) or refire): + fired[key] = now.isoformat() + fired_any = True + body = calname + if loc: + body += " \u00b7 " + loc + if start.time() != datetime.time.min or not isinstance(occ.get("DTSTART").dt, datetime.date): + body += " \u00b7 " + start.strftime("%H:%M") + try: + subprocess.run([PW_PLAY, ensure_chime()], + capture_output=True, timeout=10) + except Exception as e: + log("chime failed: %s" % e) + try: + # Identifier payloads (Open carries the + # event date; Snooze carries the exact + # reminder key) let the shell react without + # a DBus round trip — see quickshell-shell.qml. + subprocess.run( + [NOTIFY, "-a", "Calendar Reminder", + "-u", "normal", + "-c", "calendar", + "-t", "0", + "--action=cal-open:%s=OPEN" % start.strftime("%Y-%m-%d"), + "--action=cal-snooze:%s=SNOOZE 10M" % key, + "Reminder: " + summary, + body], + capture_output=True, timeout=10) + except Exception as e: + log("notify failed: %s" % e) + log("reminder fired: %s (%s)" % (summary, calname)) + except Exception as e: + log("remind: event error: %s" % e) + + if fired_any: + # prune old entries (older than 40 days) + cutoff = (now - datetime.timedelta(days=40)).isoformat() + fired = {k: v for k, v in fired.items() if v >= cutoff} + wr(REMINDER_STATE, fired) + + if snooze_dirty: + # drop expired snoozes (older than 40 days) and persist the rest + cutoff = (now - datetime.timedelta(days=40)).isoformat() + snoozes = {k: v for k, v in snoozes.items() if v >= cutoff} + wr(SNOOZE_FILE, snoozes) + + +def _mark_sync_ok(): + """Record that a sync/event write succeeded (calendar header badge).""" + now_iso = datetime.datetime.now().isoformat(timespec="seconds") + st = rd(SYNC_FILE, {}) + st.update({ + "ok": True, + "lastOk": now_iso, + "lastEffort": now_iso, + "error": "", + }) + wr(SYNC_FILE, st) + + +def _mark_sync_error(msg): + """Record a failed sync/push (calendar header badge).""" + now_iso = datetime.datetime.now().isoformat(timespec="seconds") + st = rd(SYNC_FILE, {}) + st.update({ + "ok": False, + "lastOk": st.get("lastOk", ""), + "lastEffort": now_iso, + "error": msg, + }) + wr(SYNC_FILE, st) + + +def fetch_events(): + """vdirsyncer sync -> icalendar self-scan of the local stores -> events.json. + + Each event carries uid + file so the shell popup can edit/delete a specific + item. Recurrences are expanded via recurring_ical_events (the same library + the reminder scanner uses).""" + secret = parse_env_file(SECRET_ENV) + url = secret.get("NEXTCLOUD_CALDAV_URL", "") + user = secret.get("NEXTCLOUD_CALDAV_USERNAME", "") + pw = secret.get("NEXTCLOUD_CALDAV_PASSWORD", "") + if not (url and user and pw): + log("nextcloud secret missing or incomplete; keeping existing events.json") + _mark_sync_error("Nextcloud cal secret missing/incomplete") + return + try: + write_tmp_vdirsyncer_conf(secret) + effective_url = url + if url.rstrip("/").endswith("/calendars"): + effective_url = url.rstrip("/") + "/" + user + log("syncing from %s" % effective_url) + # `collections = ["from a", "from b"]` triggers auto-discovery; feed y + # answers so the "Should it attempt to create?" prompts never hang + # in our headless runner. + y = ("y\n") * 50 + if not run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "discover", "nextcloud"], + stdin_input=y): + return + if not run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "sync"], + stdin_input=y): + return + if not run_sync_cmd([VDIRSYNCER, "-c", TMP_CONF, "sync"], + stdin_input=y): + return + finally: + if os.path.exists(TMP_CONF): + os.remove(TMP_CONF) + os.makedirs(VDIR, exist_ok=True) + wkdir = os.path.dirname(STATUS) + if wkdir: + os.makedirs(wkdir, exist_ok=True) + + try: + events = scan_local_events() + except Exception as e: + log("scan_local_events failed: %s" % e) + _mark_sync_error("scan failed: %s" % e) + return + + events.sort(key=lambda e: (e["d"], e["t"] if e["t"] else "00:00")) + now_iso = datetime.datetime.now().isoformat(timespec="seconds") + wr(EVENTS_FILE, {"fetched": now_iso, "events": events}) + _mark_sync_ok() + log("wrote %d events" % len(events)) + + +def scan_local_events(): + """Expand every .ics in the vdirsyncer stores into flat event rows, expanded + from recurrence rules. Returns list of dicts; the shell popup and the + edit/delete commands both rely on the uid/file fields.""" + import icalendar + import recurring_ical_events + + now = datetime.datetime.now() + window_start = now.date() + window_end = window_start + datetime.timedelta(days=EVENT_DAYS) + events = [] + + for calname in list_calendars(): + caldir = os.path.join(VDIR, calname) + try: + names = sorted(os.listdir(caldir)) + except OSError: + continue + for fn in names: + if not fn.endswith(".ics"): + continue + path = os.path.join(caldir, fn) + try: + with open(path, "rb") as f: + data = f.read().decode("utf-8", "replace") + except OSError as e: + log("scan: cannot read %s: %s" % (path, e)) + continue + try: + cal = icalendar.Calendar.from_ical(data) + except Exception as e: + log("scan: cannot parse %s: %s" % (path, e)) + continue + + for vevent in cal.walk("VEVENT"): + uid = str(vevent.get("UID") or str(uuid.uuid4())) + summary = str(vevent.get("SUMMARY") or "(no title)").strip() + loc = str(vevent.get("LOCATION") or "").strip() + rrule = str(vevent.get("RRULE") or "") + if vevent.get("DTSTART") is None: + continue + comp = icalendar.Calendar() + for tz in cal.walk("VTIMEZONE"): + comp.add_component(tz) + comp.add_component(vevent) + try: + occs = recurring_ical_events.of(comp).between( + window_start, window_end) + except Exception as e: + log("scan: recurrence error in %s: %s" % (fn, e)) + continue + for occ in occs: + st_raw = occ.get("DTSTART").dt + allDay = isinstance(st_raw, datetime.date) and not isinstance( + st_raw, datetime.datetime) + st = _to_local_naive(st_raw, allDay) + datepart = st.strftime("%Y-%m-%d") + timepart = st.strftime("%H:%M") if not allDay else "" + + en_raw = occ.get("DTEND") + if en_raw is not None: + en_raw = en_raw.dt + if en_raw is None: + end_naive = st + (datetime.timedelta(days=1) if allDay + else datetime.timedelta(hours=1)) + else: + end_naive = _to_local_naive(en_raw, allDay) + endpart = end_naive.strftime("%H:%M") if not allDay else "" + + # drop timed occurrences that already finished + if not allDay and end_naive < now: + continue + + events.append({ + "d": datepart, + "t": timepart, + "e": endpart, + "title": summary, + "cal": calname, + "allDay": allDay, + "rep": bool(rrule), + "rrule": rrule, + "loc": loc, + "rem": reminder_minutes(vevent), + "uid": uid, + "file": path, + }) + return events + + +def _to_local_naive(dt, allDay): + """Normalise an occurrence start/end to a local naive datetime.""" + if allDay and isinstance(dt, datetime.date) and not isinstance(dt, datetime.datetime): + return datetime.datetime.combine(dt, datetime.time.min) + if dt.tzinfo is not None: + return dt.astimezone().replace(tzinfo=None) + return dt + + +def refresh_local_cache(): + """Re-scan the local vdirsyncer stores (no network) and rewrite the event + cache so a mutation (add/edit/delete) shows up in the popup immediately + without waiting for the next timed sync.""" + try: + events = scan_local_events() + wr(EVENTS_FILE, { + "events": events, + "fetched": datetime.datetime.now().isoformat(timespec="seconds"), + }) + log("rescanned %d events" % len(events)) + except Exception as e: + log("refresh_local_cache failed: %s" % e) + + +def fetch_weather(): + """Open-Meteo forecast for the stored location -> weather.json. Keeps a + current block (temp/feels/code/humidity/wind), per-day rows (min/max/precip + chance/max wind, 7 days) and an hourly trace for the next 24h so the shell + can draw an hourly strip / more days.""" + loc = rd(LOC_FILE, None) + if not loc: + loc = dict(DEFAULT_LOC) + wr(LOC_FILE, loc) + lat, lon = str(loc.get("lat", "")), str(loc.get("lon", "")) + if not (lat and lon): + return + params = { + "latitude": lat, + "longitude": lon, + "current": "temperature_2m,apparent_temperature,weather_code,relative_humidity_2m,wind_speed_10m", + "hourly": "temperature_2m,weather_code,precipitation_probability,wind_speed_10m", + "daily": "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,wind_speed_10m_max", + "forecast_days": "7", + "timezone": "auto", + } + url = "https://api.open-meteo.com/v1/forecast?" + urllib.parse.urlencode(params) + try: + data = json.loads(http_get(url)) + except (OSError, ValueError) as e: + log("weather fetch failed: %s" % e) + return + + cur = data.get("current") or {} + daily = data.get("daily") or {} + hourly = data.get("hourly") or {} + htime = hourly.get("time") or [] + + def hourly_row(i): + return { + "h": (htime[i][11:16] if i < len(htime) else ""), + "t": (hourly.get("temperature_2m") or [])[i], + "code": (hourly.get("weather_code") or [])[i], + "pop": (hourly.get("precipitation_probability") or [])[i], + "wind": (hourly.get("wind_speed_10m") or [])[i], + } + + out = { + "name": loc.get("name", "?"), + "lat": lat, + "lon": lon, + "fetched": datetime.datetime.now().isoformat(timespec="seconds"), + "current": { + "t": cur.get("temperature_2m"), + "feels": cur.get("apparent_temperature"), + "code": cur.get("weather_code"), + "hum": cur.get("relative_humidity_2m"), + "wind": cur.get("wind_speed_10m"), + }, + "hourly": [ + hourly_row(i) + for i in range(min(24, len(htime))) + ], + "daily": [ + { + "d": (daily.get("time") or [])[i], + "code": (daily.get("weather_code") or [])[i], + "tmin": (daily.get("temperature_2m_min") or [])[i], + "tmax": (daily.get("temperature_2m_max") or [])[i], + "pop": (daily.get("precipitation_probability_max") or [])[i], + "wind": (daily.get("wind_speed_10m_max") or [])[i], + } + for i in range(min(7, len(daily.get("time") or []))) + ], + } + wr(WEATHER_FILE, out) + log("wrote weather for %s" % out["name"]) + + +def resolve_location(query): + """'lat,lon' passthrough or Open-Meteo geocoding of a town name.""" + q = query.strip() + if re.fullmatch(r"[-+]?\d+(?:\.\d+)?\s*,\s*[-+]?\d+(?:\.\d+)?", q): + lat, lon = [p.strip() for p in q.split(",")] + return {"name": q, "lat": lat, "lon": lon} + url = ("https://geocoding-api.open-meteo.com/v1/search?name=" + + urllib.parse.quote(q) + "&count=1&language=en&format=json") + try: + data = json.loads(http_get(url)) + except (OSError, ValueError) as e: + log("geocoding failed: %s" % e) + return None + res = (data.get("results") or [None])[0] + if not res: + log("geocoding: no match for %r" % q) + return None + name = ", ".join(x for x in [ + res.get("name"), + res.get("admin1"), + res.get("country_code"), + ] if x) + return {"name": name, "lat": str(res["latitude"]), "lon": str(res["longitude"])} + + +def snooze(key, minutes): + """Delay a specific reminder (key = uid|occurrence|trigger as shown in the + notification) by N minutes. The shell calls this when the SNOOZE action is + picked; remind() skips the key until the deadline passes, then fires it + again (the fired marker is cleared so the re-fire isn't suppressed).""" + try: + minutes = int(minutes) + except (TypeError, ValueError): + minutes = 10 + if minutes < 1: + minutes = 10 + snoozes = rd(SNOOZE_FILE, {}) + snoozes[key] = (datetime.datetime.now().astimezone() + + datetime.timedelta(minutes=minutes)).isoformat(timespec="seconds") + wr(SNOOZE_FILE, snoozes) + # let the notification appear again once the deadline passes + fired = rd(REMINDER_STATE, {}) + fired.pop(key, None) + wr(REMINDER_STATE, fired) + print(json.dumps({"ok": True, "key": key, "until": snoozes[key]}, + ensure_ascii=False)) + sys.stdout.flush() + + +def dump_cache(): + """Merge the cache files into one stdout doc for the shell.""" + ev = rd(EVENTS_FILE, None) + wx = rd(WEATHER_FILE, None) + loc = rd(LOC_FILE, None) + print(json.dumps({ + "events": (ev or {}).get("events", []), + "eventsFetched": (ev or {}).get("fetched", ""), + "weather": wx, + "loc": loc, + "cals": list_calendars(), + "sync": rd(SYNC_FILE, None), + }, ensure_ascii=False)) + sys.stdout.flush() + + +def main(): + args = sys.argv[1:] + mode = args[0] if args else "read" + + if mode == "sync": + fetch_events() + fetch_weather() + elif mode == "setloc" and len(args) > 1: + loc = resolve_location(args[1]) + if loc: + wr(LOC_FILE, loc) + fetch_weather() + f = rd(WEATHER_FILE, None) + if f: + f["name"] = loc["name"] + f["lat"] = loc["lat"] + f["lon"] = loc["lon"] + wr(WEATHER_FILE, f) + dump_cache() + elif mode == "read": + dump_cache() + elif mode == "cals": + print(json.dumps(list_calendars(), ensure_ascii=False)) + sys.stdout.flush() + elif mode == "add" and len(args) > 1: + try: + params = json.loads(args[1]) + except ValueError: + params = {} + print(json.dumps(add_event(params), ensure_ascii=False)) + sys.stdout.flush() + refresh_local_cache() + dump_cache() + elif mode == "edit" and len(args) > 1: + try: + params = json.loads(args[1]) + except ValueError: + params = {} + print(json.dumps(edit_event(params), ensure_ascii=False)) + sys.stdout.flush() + refresh_local_cache() + dump_cache() + elif mode == "delete" and len(args) > 1: + try: + params = json.loads(args[1]) + except ValueError: + params = {} + print(json.dumps(delete_event(params), ensure_ascii=False)) + sys.stdout.flush() + refresh_local_cache() + dump_cache() + elif mode == "snooze" and len(args) > 1: + snooze(args[1], args[2] if len(args) > 2 else "10") + elif mode == "remind": + remind() + else: + log("unknown mode %r" % mode) + sys.exit(2) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/home-manager/modules/quickshell-launch.py b/home-manager/modules/quickshell-launch.py new file mode 100644 index 0000000..56616f9 --- /dev/null +++ b/home-manager/modules/quickshell-launch.py @@ -0,0 +1,393 @@ +#!/usr/bin/env python3 +"""qs-launch — wofi-based launcher modes: emoji pick, calculator, DuckDuckGo search. + +Subcommands: + emoji feed a curated emoji list into wofi --dmenu, copy the picked emoji + calc type an expression in wofi, evaluate with qalc, copy + toast result + search type a query in wofi, open DuckDuckGo in the default browser + +All binary paths are injected via $QS_* env vars by the .local/bin/qs-launch +wrapper so execution works regardless of PATH (same pattern as qs-bt's +$BLUECTL). When no DISPLAY/WAYLAND_DISPLAY is present, wofi can't open, so each +mode exits silently with code 1. +""" + +import os +import subprocess +import sys +import urllib.parse + +# Curated emoji list: " ". Searchable by name, output splits +# on the first whitespace so only the emoji character gets copied. +EMOJI = """😀 grin +😃 smiley +😁 beam +😂 joy +🤣 rofl +😊 blush +🙂 slight smile +😉 wink +😍 heart eyes +😘 kiss +🤩 star struck +🥳 party +😎 cool +🤓 nerd +😇 halo +😏 smirk +😜 winky tongue +🥺 plead +😬 grimace +🤔 think +🤯 mind blown +😴 sleepy +😭 cry +😢 sad +😞 disappointed +😠 angry +😤 triumph +🤬 swear +🙄 roll eyes +😐 neutral +😑 expressionless +🙂🙃 upside down +😱 scream +🤒 sick +🤕 hurt +💀 skull +👻 ghost +👽 alien +🤖 robot +🎃 pumpkin +💩 poo +🙌 yes +👏 clap +🙏 pray +🤝 handshake +👍 thumbs up +👎 thumbs down +👊 fist +✊ raised fist +🤞 crossed fingers +✌️ peace +👋 hello wave +🖐️ hand +✍️ writing +💪 muscle +🫶 heart hands +🦾 bionic arm +🧠 brain +👀 eyes +👁️ eye +👄 lips +👅 taste tongue +🧛 vampire +🧟 zombie +❤️ heart +🧡 orange heart +💛 yellow heart +💚 green heart +💙 blue heart +💜 purple heart +🖤 black heart +🤍 white heart +🤎 brown heart +💔 broken heart +❤️‍🔥 heart fire +💯 hundred +💥 boom +💫 dizzy +✨ sparkles +🔥 fire +🌟 star +⭐ star white +☀️ sun +🌙 moon +🌑 new moon +🌈 rainbow +☁️ cloud +⛈️ storm +❄️ snow +🌊 ocean wave +🌍 earth +🌺 hibiscus +🌸 cherry blossom +🌹 rose +🌷 tulip +🌵 cactus +🌴 palm +🍀 clover +🍄 mushroom +🐶 dog +🐱 cat +🦊 fox +🐻 bear +🐼 panda +🐨 koala +🦁 lion +🐯 tiger +🐸 frog +🐵 monkey +🐧 penguin +🦄 unicorn +🐝 bee +🦋 butterfly +🐢 turtle +🐙 octopus +🦈 shark +🐬 dolphin +🍍 pineapple +🥭 mango +🍒 cherries +🍓 strawberry +🍑 peach +🥑 avocado +🍕 pizza +🍔 burger +🍟 fries +🌭 hotdog +🍿 popcorn +🌮 taco +🍰 cake +🍩 donut +🥐 croissant +☕ coffee +🍵 tea +🍺 beer +🍻 cheers +⚽ football +🏀 basketball +🎾 tennis +⚾ baseball +🏈 american football +🎮 game +🎲 dice +🎯 target +🎵 music +🎶 notes +🎤 mic +🎧 headphones +📻 radio +🎬 movie +📺 tv +💻 laptop +📱 phone +🖥️ computer +🖱️ mouse +💾 floppy +💿 disc +🔋 battery +🔌 plug +💡 bulb +🔦 flashlight +🗑️ trash +🔒 lock +🔓 unlocked +🔑 key +✂️ scissors +📎 paperclip +📌 pin +📍 location +🧲 magnet +🛠️ tools +⚙️ gear +🔧 wrench +✏️ pencil +🖊️ pen +📖 book +📚 books +📄 doc +📁 folder +📦 package +✉️ mail +📝 memo +📊 chart +💰 money +💳 card +🧾 receipt +🏆 trophy +🥇 gold +🥈 silver +🎁 gift +🎈 balloon +🎉 party popper +🥂 toast +🎊 confetti +🧹 clean +🧽 sponge +⏰ alarm +⏱️ stopwatch +🕰️ clock +📅 calendar +📆 date +🔔 bell +🔕 bell off +❌ cross +✅ check +⚠️ warning +🚫 no entry +➕ plus +➖ minus +➗ divide +✖️ multiply +🔄 refresh +⬆️ up +⬇️ down +⬅️ left +➡️ right +↩️ return +🔙 back +🔜 soon +🌐 globe +🚀 rocket +🛸 saucer +✈️ plane +🚗 car +🚕 taxi +🚌 bus +🚂 train +🚲 bike +🏍️ motorcycle +👻 spooky +☠️ skull crossbones +💣 bomb +🚁 chopper +🚢 ship +🚄 bullet train +🚦 traffic light +⛽ fuel +🅿️ parking +🚧 roadwork +🌉 bridge +🏰 castle +⛺ camp +🌋 volcano +🗼 tower +🏯 pagoda +🌆 city dusk +🌃 city night +🏙️ skyline +🖼️ frame +🎨 palette +🖌️ brush +🧶 yarn +🧵 thread +💍 ring +👟 sneaker +👠 heel +🧢 cap +🎓 graduate +👒 hat +🧣 scarf +🧤 gloves +🧥 coat +👔 tie +👕 shirt +👖 jeans +💭 thought +💬 speech +🗯️ anger +⌨️ keyboard +🖨️ printer +🧮 abacus +🕹️ joystick +🎳 bowling +🏓 ping pong +🏸 badminton +🥊 boxing +🥋 martial arts +🎽 running shirt +🚴 cyclist +🧗 climber +🏊 swimmer +🎪 circus +🎠 carousel +🗿 moai +🏛️ classical +🏟️ stadium +🏫 school +🏥 hospital +🏦 bank +🏪 store +🏭 factory +🏝️ island +🏖️ beach +⛱️ umbrella +🧭 compass""" + + +def run(command, **kwargs): + return subprocess.run(command, capture_output=True, text=True, **kwargs) + + +def wofi(entries, prompt, exec_search=False): + cmd = [os.environ.get("QS_WOFI", "wofi"), "--dmenu", "--prompt", prompt] + if exec_search: + cmd.append("--exec-search") + result = run(cmd, input=entries) + if result.returncode != 0: + return None + return result.stdout.rstrip("\n") + + +def cmd_mode(): + if "WAYLAND_DISPLAY" not in os.environ and "DISPLAY" not in os.environ: + return 1 + sub = sys.argv[1] if len(sys.argv) > 1 else "" + if sub == "emoji": + return emoji_mode() + if sub == "calc": + return calc_mode() + if sub == "search": + return search_mode() + print(__doc__, file=sys.stderr) + return 2 + + +def emoji_mode(): + picked = wofi(EMOJI, "\U0001f600 Emoji") + if not picked: + return 0 + char = picked.split(" ", 1)[0] + if not char: + return 0 + wl_copy = os.environ.get("QS_WLCOPY", "wl-copy") + run([wl_copy, char]) + return 0 + + +def calc_mode(): + expr = wofi("", "=? \u2014 1+2 then Enter", exec_search=True) + if not expr: + return 0 + qalc = os.environ.get("QS_QALC", "qalc") + result = run([qalc, "-t", expr]) + if result.returncode != 0: + return 0 + answer = result.stdout.strip() + wl_copy = os.environ.get("QS_WLCOPY", "wl-copy") + run([wl_copy, answer]) + notify(os.environ.get("QS_NOTIFY", "notify-send"), + "\U0001f4d0 " + expr, "= " + answer + " (copied)") + return 0 + + +def search_mode(): + query = wofi("", "Search the web", exec_search=True) + if not query: + return 0 + url = "https://duckduckgo.com/?q=" + urllib.parse.quote_plus(query) + xdg_open = os.environ.get("QS_XDGO", "xdg-open") + subprocess.Popen([xdg_open, url]) + return 0 + + +def notify(sender, title, body): + try: + run([sender, "-a", "qs-launch", title, body]) + except OSError: + pass + + +if __name__ == "__main__": + raise SystemExit(cmd_mode()) \ No newline at end of file diff --git a/home-manager/modules/quickshell-shell.qml b/home-manager/modules/quickshell-shell.qml new file mode 100644 index 0000000..931c40a --- /dev/null +++ b/home-manager/modules/quickshell-shell.qml @@ -0,0 +1,6693 @@ +//@ pragma UseQApplication +// ~/.config/quickshell/shell.qml +// QuickShell desktop shell for Hyprland (Nix-Vibe hp-laptop). +// +// "Sci-fi dark glass + vivid neon" theme: +// - frosted-glass pill bar (blur via Hyprland layerrule, namespace qs-neon-bar) +// - workspace pills with neon bloom on the active one +// - icon status row: battery (UPower), volume (PipeWire), brightness +// (brightnessctl), Wi-Fi (Networking); scroll volume/brightness chips +// - notification center (native NotificationServer): toasts + history/DND/clear +// - workspace overview (SUPER+W): lite mission control, jump with a click +// - Orbitron display font, Symbols Nerd Font icons +// - HUD corner brackets per monitor with a slow pulse +// - animated accent sweep along the bar's bottom edge +// QuickShell live-reloads this file on save. +import Quickshell +import Quickshell.Hyprland +import Quickshell.Io +import Quickshell.Networking +import Quickshell.Services.Mpris +import Quickshell.Services.Notifications +import Quickshell.Services.Pipewire +import Quickshell.Services.UPower +import Quickshell.Services.SystemTray +import Quickshell.Wayland +import QtQuick +import QtQuick.Layouts + +ShellRoot { + id: root + + // Keep-awake: while true the compositor is told the session must not go + // idle, so hypridle's lock/blank/suspend listeners never fire. + // Session-lifetime only (resets on logout) — it's meant as a temporary hold. + property bool keepAwake: false + + // ---------- system stats (qs-stats probe) ---------- + // Parsed JSON from the stats probe; refreshed on the slow bar timer and + // fast while the popup is open. + property var stats: null + readonly property string statsScript: "/home/petere/.local/bin/qs-stats" + + // Pinned flake.lock info (nixpkgs node) injected at build time by hyprland.nix. + readonly property var flakeInfo: @FLAKEINFO@ + + // Build-time theme colors injected by hyprland.nix from the shared palette + // (home-manager/modules/palette.nix). At startup these may be repainted by a + // wallpaper-driven palette (~/.cache/quickshell/theme.json, see + // applyRuntimeTheme/themeOverrideProc below). + readonly property var themeColors: @THEMECOLORS@ + + // helpers for the chip/popup display values + function statsHot() { + if (root.stats === null || root.stats.temps === undefined || root.stats.temps.length === 0) + return 0; + let hot = 0; + for (const t of root.stats.temps) + hot = Math.max(hot, t.temp); + return hot; + } + + function statsAgeDays() { + if (root.flakeInfo === null || root.flakeInfo.lastModified === undefined) + return -1; + return Math.max(0, Math.floor((Date.now() / 1000 - root.flakeInfo.lastModified) / 86400)); + } + + function statsRev() { + if (root.flakeInfo === null || root.flakeInfo.rev === undefined) + return ""; + return root.flakeInfo.rev.substring(0, 8); + } + + // ---------- autostart app manager ---------- + property var appsEntries: [] + property bool appsAddOpen: false + property string appsNewName: "" + property string appsNewCmd: "" + + // ---------- battery charge limit (gear quick-settings panel) ---------- + // Backend `battery-charge-limit` (NixOS hardware/battery-limit modules) is + // provided by both the HP module (acpi_call SBCO/SBCC) and the ThinkPad + // module (native charge_control_start/end_threshold sysfs). The on/off + // switch refreshes the persisted state file via `status` and toggles via + // `sudo