diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore index eea2bc6..32cf8df 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,12 @@ # Ignore build outputs from performing a nix-build or `nix build` command result result-* +repl-result* -# I don't want to do a commit everytime that I want to update stuff -flake.lock +# ---> Kate +# Ignore kate's swap files +*.kate-swp + + +# ---> Direnv +.direnv diff --git a/.helix/languages.toml b/.helix/languages.toml new file mode 100644 index 0000000..3c8a111 --- /dev/null +++ b/.helix/languages.toml @@ -0,0 +1,3 @@ +[[language]] +name = "nix" +formatter = { command = "alejandra" } diff --git a/.justfile b/.justfile new file mode 100644 index 0000000..9b4041a --- /dev/null +++ b/.justfile @@ -0,0 +1,33 @@ +# Hide the default option from the recipes list +_default: print-recipes + +# Escape codes for text formatting +bold := `tput bold` +normal := `tput sgr0` + +find-results: + find . -name '*result*' + +print-recipes: + @just --list + +update: + nix flake update + +@edit-secrets: + git clone ssh://forgejo@git.toast003.xyz:4222/Toast/nix-secrets.git /tmp/secrets + sed -i 's\git+ssh://forgejo@git.toast003.xyz:4222/Toast/nix-secrets\/tmp/secrets\g' flake.nix + nix flake update secrets + echo "{{bold}}All done!" + echo "{{normal}}Remember to restore flake.nix" + echo "" + echo "" + +alias build := build-nixos +# Build a NixOS configuration +build-nixos host=`hostname`: + nom build .#nixosConfigurations.{{host}}.config.system.build.toplevel + +# nix-diff with some parameters piped to less +nix-diff left right: + nix-diff --color=always --skip-already-compared {{left}} {{right}} | less -F diff --git a/.vscode/settings.json b/.vscode/settings.json index 6321126..4b907f6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,15 @@ { - "editor.detectIndentation": true, - "editor.insertSpaces": false, - "editor.tabSize": 2, - "editor.renderWhitespace": "all", - "editor.defaultFormatter": "jnoortheen.nix-ide" + "editor.detectIndentation": true, + "editor.insertSpaces": true, + "editor.defaultFormatter": "jnoortheen.nix-ide", + "nix.formatterPath": "alejandra", + "nix.serverSettings": { + "nil": { + "formatting": { + "command": [ + "alejandra" + ] + } + } + } } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..8c9a225 --- /dev/null +++ b/flake.lock @@ -0,0 +1,608 @@ +{ + "nodes": { + "agenix": { + "inputs": { + "darwin": [], + "home-manager": "home-manager", + "nixpkgs": [ + "nixpkgs-raw" + ], + "systems": "systems" + }, + "locked": { + "lastModified": 1762618334, + "narHash": "sha256-wyT7Pl6tMFbFrs8Lk/TlEs81N6L+VSybPfiIgzU8lbQ=", + "owner": "ryantm", + "repo": "agenix", + "rev": "fcdea223397448d35d9b31f798479227e80183f6", + "type": "github" + }, + "original": { + "owner": "ryantm", + "repo": "agenix", + "type": "github" + } + }, + "catppuccin": { + "inputs": { + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1765990358, + "narHash": "sha256-l8x0gU8mnYaGMl+gWrsSHKBJlZWD8KXJfHTkRlFiPI0=", + "owner": "catppuccin", + "repo": "nix", + "rev": "de1b60ca45a578f59f7d84c8d338b346017b2161", + "type": "github" + }, + "original": { + "owner": "catppuccin", + "repo": "nix", + "type": "github" + } + }, + "catppuccin-konsole": { + "flake": false, + "locked": { + "lastModified": 1720277724, + "narHash": "sha256-d5+ygDrNl2qBxZ5Cn4U7d836+ZHz77m6/yxTIANd9BU=", + "owner": "catppuccin", + "repo": "konsole", + "rev": "3b64040e3f4ae5afb2347e7be8a38bc3cd8c73a8", + "type": "github" + }, + "original": { + "owner": "catppuccin", + "repo": "konsole", + "type": "github" + } + }, + "copyparty": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs-raw" + ] + }, + "locked": { + "lastModified": 1766045858, + "narHash": "sha256-lsbdHVSc5EB2+XgKDbeG1DjLLY5DnzlKQIPV0uQu/bQ=", + "owner": "9001", + "repo": "copyparty", + "rev": "0e6b167167eaf04036df8576f1ea96bc116ea951", + "type": "github" + }, + "original": { + "owner": "9001", + "repo": "copyparty", + "type": "github" + } + }, + "eza-themes": { + "flake": false, + "locked": { + "lastModified": 1765813820, + "narHash": "sha256-WcwzKm2mi/tyA+zZCpyvTdrOrZ1R1ENA3t622SGzFas=", + "owner": "eza-community", + "repo": "eza-themes", + "rev": "1239cb1dd23fa8b70865550db77701b164a53cde", + "type": "github" + }, + "original": { + "owner": "eza-community", + "repo": "eza-themes", + "type": "github" + } + }, + "flake-utils": { + "locked": { + "lastModified": 1678901627, + "narHash": "sha256-U02riOqrKKzwjsxc/400XnElV+UtPUQWpANPlyazjH0=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "93a2b84fc4b70d9e089d029deacc3583435c2ed6", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "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" + } + }, + "flake-utils_3": { + "inputs": { + "systems": "systems_3" + }, + "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" + } + }, + "flakey-profile": { + "locked": { + "lastModified": 1712898590, + "narHash": "sha256-FhGIEU93VHAChKEXx905TSiPZKga69bWl1VB37FK//I=", + "owner": "lf-", + "repo": "flakey-profile", + "rev": "243c903fd8eadc0f63d205665a92d4df91d42d9d", + "type": "github" + }, + "original": { + "owner": "lf-", + "repo": "flakey-profile", + "type": "github" + } + }, + "flakey-profile_2": { + "locked": { + "lastModified": 1712898590, + "narHash": "sha256-FhGIEU93VHAChKEXx905TSiPZKga69bWl1VB37FK//I=", + "owner": "lf-", + "repo": "flakey-profile", + "rev": "243c903fd8eadc0f63d205665a92d4df91d42d9d", + "type": "github" + }, + "original": { + "owner": "lf-", + "repo": "flakey-profile", + "type": "github" + } + }, + "home-manager": { + "inputs": { + "nixpkgs": [ + "agenix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1745494811, + "narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "home-manager-unstable": { + "inputs": { + "nixpkgs": [ + "nixpkgs-unstable-raw" + ] + }, + "locked": { + "lastModified": 1765980955, + "narHash": "sha256-rB45jv4uwC90vM9UZ70plfvY/2Kdygs+zlQ07dGQFk4=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "89c9508bbe9b40d36b3dc206c2483ef176f15173", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "home-manager", + "type": "github" + } + }, + "home-manager_2": { + "inputs": { + "nixpkgs": [ + "nixpkgs-raw" + ] + }, + "locked": { + "lastModified": 1765979862, + "narHash": "sha256-/r9/1KamvbHJx6I40H4HsSXnEcBAkj46ZwibhBx9kg0=", + "owner": "nix-community", + "repo": "home-manager", + "rev": "d3135ab747fd9dac250ffb90b4a7e80634eacbe9", + "type": "github" + }, + "original": { + "owner": "nix-community", + "ref": "release-25.11", + "repo": "home-manager", + "type": "github" + } + }, + "jovian": { + "inputs": { + "nix-github-actions": "nix-github-actions", + "nixpkgs": [ + "nixpkgs-unstable-raw" + ] + }, + "locked": { + "lastModified": 1766067735, + "narHash": "sha256-cRC/rOYRtZNzc5y9nTccozyo/mkI4/1eFE33Aqgs+SQ=", + "owner": "Jovian-Experiments", + "repo": "Jovian-NixOS", + "rev": "34a16089be30f77ac9444907ec97c02b4b711896", + "type": "github" + }, + "original": { + "owner": "Jovian-Experiments", + "repo": "Jovian-NixOS", + "type": "github" + } + }, + "lix": { + "flake": false, + "locked": { + "lastModified": 1765883751, + "narHash": "sha256-clrWX/t2swPGBVs50Yegq2HK3q5bbwOt3kWMsL7JIZM=", + "rev": "fc0073f54095f15ee272621d4746eb9f40946385", + "type": "tarball", + "url": "https://git.lix.systems/api/v1/repos/lix-project/lix/archive/fc0073f54095f15ee272621d4746eb9f40946385.tar.gz?rev=fc0073f54095f15ee272621d4746eb9f40946385" + }, + "original": { + "type": "tarball", + "url": "https://git.lix.systems/lix-project/lix/archive/main.tar.gz" + } + }, + "lix-module": { + "inputs": { + "flake-utils": "flake-utils_2", + "flakey-profile": "flakey-profile", + "lix": [ + "lix" + ], + "nixpkgs": [ + "nixpkgs-raw" + ] + }, + "locked": { + "lastModified": 1764519849, + "narHash": "sha256-XnNABKfIYKSimQVvKc9FnlC2H0LurOhd9MS6l0Z67lE=", + "rev": "6c95c0b6f73f831226453fc6905c216ab634c30f", + "type": "tarball", + "url": "https://git.lix.systems/api/v1/repos/lix-project/nixos-module/archive/6c95c0b6f73f831226453fc6905c216ab634c30f.tar.gz?rev=6c95c0b6f73f831226453fc6905c216ab634c30f" + }, + "original": { + "type": "tarball", + "url": "https://git.lix.systems/lix-project/nixos-module/archive/main.tar.gz" + } + }, + "lix-module-unstable": { + "inputs": { + "flake-utils": "flake-utils_3", + "flakey-profile": "flakey-profile_2", + "lix": [ + "lix" + ], + "nixpkgs": [ + "nixpkgs-unstable-raw" + ] + }, + "locked": { + "lastModified": 1764519849, + "narHash": "sha256-XnNABKfIYKSimQVvKc9FnlC2H0LurOhd9MS6l0Z67lE=", + "rev": "6c95c0b6f73f831226453fc6905c216ab634c30f", + "type": "tarball", + "url": "https://git.lix.systems/api/v1/repos/lix-project/nixos-module/archive/6c95c0b6f73f831226453fc6905c216ab634c30f.tar.gz?rev=6c95c0b6f73f831226453fc6905c216ab634c30f" + }, + "original": { + "type": "tarball", + "url": "https://git.lix.systems/lix-project/nixos-module/archive/main.tar.gz" + } + }, + "nix-flatpak": { + "locked": { + "lastModified": 1754777568, + "narHash": "sha256-0bBqT+3XncgF8F03RFAamw9vdf0VmaDoIJLTGkjfQZs=", + "owner": "gmodena", + "repo": "nix-flatpak", + "rev": "62f636b87ef6050760a8cb325cadb90674d1e23e", + "type": "github" + }, + "original": { + "owner": "gmodena", + "ref": "main", + "repo": "nix-flatpak", + "type": "github" + } + }, + "nix-github-actions": { + "inputs": { + "nixpkgs": [ + "jovian", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1729697500, + "narHash": "sha256-VFTWrbzDlZyFHHb1AlKRiD/qqCJIripXKiCSFS8fAOY=", + "owner": "zhaofengli", + "repo": "nix-github-actions", + "rev": "e418aeb728b6aa5ca8c5c71974e7159c2df1d8cf", + "type": "github" + }, + "original": { + "owner": "zhaofengli", + "ref": "matrix-name", + "repo": "nix-github-actions", + "type": "github" + } + }, + "nix-impermanence": { + "locked": { + "lastModified": 1737831083, + "narHash": "sha256-LJggUHbpyeDvNagTUrdhe/pRVp4pnS6wVKALS782gRI=", + "owner": "nix-community", + "repo": "impermanence", + "rev": "4b3e914cdf97a5b536a889e939fb2fd2b043a170", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "impermanence", + "type": "github" + } + }, + "nix-index-db": { + "inputs": { + "nixpkgs": [ + "nixpkgs-raw" + ] + }, + "locked": { + "lastModified": 1765267181, + "narHash": "sha256-d3NBA9zEtBu2JFMnTBqWj7Tmi7R5OikoU2ycrdhQEws=", + "owner": "Mic92", + "repo": "nix-index-database", + "rev": "82befcf7dc77c909b0f2a09f5da910ec95c5b78f", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "nix-index-database", + "type": "github" + } + }, + "nix-index-db-unstable": { + "inputs": { + "nixpkgs": [ + "nixpkgs-unstable-raw" + ] + }, + "locked": { + "lastModified": 1765267181, + "narHash": "sha256-d3NBA9zEtBu2JFMnTBqWj7Tmi7R5OikoU2ycrdhQEws=", + "owner": "Mic92", + "repo": "nix-index-database", + "rev": "82befcf7dc77c909b0f2a09f5da910ec95c5b78f", + "type": "github" + }, + "original": { + "owner": "Mic92", + "repo": "nix-index-database", + "type": "github" + } + }, + "nixos-hardware": { + "locked": { + "lastModified": 1764440730, + "narHash": "sha256-ZlJTNLUKQRANlLDomuRWLBCH5792x+6XUJ4YdFRjtO4=", + "owner": "NixOS", + "repo": "nixos-hardware", + "rev": "9154f4569b6cdfd3c595851a6ba51bfaa472d9f3", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "master", + "repo": "nixos-hardware", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1763966396, + "narHash": "sha256-6eeL1YPcY1MV3DDStIDIdy/zZCDKgHdkCmsrLJFiZf0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "5ae3b07d8d6527c42f17c876e404993199144b6a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-raw": { + "locked": { + "lastModified": 1765838191, + "narHash": "sha256-m5KWt1nOm76ILk/JSCxBM4MfK3rYY7Wq9/TZIIeGnT8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c6f52ebd45e5925c188d1a20119978aa4ffd5ef6", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-25.11", + "type": "indirect" + } + }, + "nixpkgs-unstable-raw": { + "locked": { + "lastModified": 1765779637, + "narHash": "sha256-KJ2wa/BLSrTqDjbfyNx70ov/HdgNBCBBSQP3BIzKnv4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1306659b587dc277866c7b69eb97e5f07864d8c4", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-unstable", + "type": "indirect" + } + }, + "plasma-manager": { + "inputs": { + "home-manager": [ + "home-manager-unstable" + ], + "nixpkgs": [ + "nixpkgs-unstable-raw" + ] + }, + "locked": { + "lastModified": 1763909441, + "narHash": "sha256-56LwV51TX/FhgX+5LCG6akQ5KrOWuKgcJa+eUsRMxsc=", + "owner": "nix-community", + "repo": "plasma-manager", + "rev": "b24ed4b272256dfc1cc2291f89a9821d5f9e14b4", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "plasma-manager", + "type": "github" + } + }, + "root": { + "inputs": { + "agenix": "agenix", + "catppuccin": "catppuccin", + "catppuccin-konsole": "catppuccin-konsole", + "copyparty": "copyparty", + "eza-themes": "eza-themes", + "home-manager": "home-manager_2", + "home-manager-unstable": "home-manager-unstable", + "jovian": "jovian", + "lix": "lix", + "lix-module": "lix-module", + "lix-module-unstable": "lix-module-unstable", + "nix-flatpak": "nix-flatpak", + "nix-impermanence": "nix-impermanence", + "nix-index-db": "nix-index-db", + "nix-index-db-unstable": "nix-index-db-unstable", + "nixos-hardware": "nixos-hardware", + "nixpkgs-raw": "nixpkgs-raw", + "nixpkgs-unstable-raw": "nixpkgs-unstable-raw", + "plasma-manager": "plasma-manager", + "secrets": "secrets", + "sops-nix": "sops-nix" + } + }, + "secrets": { + "flake": false, + "locked": { + "lastModified": 1766143747, + "narHash": "sha256-bG4QoCZLUDrubYFuRvxiXhycBD3R+UjrzXrNZ+qRnio=", + "ref": "refs/heads/main", + "rev": "8921f23861a82f0f8d706c276bc738ca72c053b1", + "revCount": 41, + "type": "git", + "url": "ssh://forgejo@git.toast003.xyz:4222/Toast/nix-secrets" + }, + "original": { + "type": "git", + "url": "ssh://forgejo@git.toast003.xyz:4222/Toast/nix-secrets" + } + }, + "sops-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs-unstable-raw" + ] + }, + "locked": { + "lastModified": 1765836173, + "narHash": "sha256-hWRYfdH2ONI7HXbqZqW8Q1y9IRbnXWvtvt/ONZovSNY=", + "owner": "Mic92", + "repo": "sops-nix", + "rev": "443a7f2e7e118c4fc63b7fae05ab3080dd0e5c63", + "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" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_3": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix old mode 100755 new mode 100644 index 8c8c19f..8bd2ed9 --- a/flake.nix +++ b/flake.nix @@ -1,84 +1,239 @@ { -description = "Configuration for Everest"; + description = "Configuration for Everest"; -inputs = { -nixpkgs.url = "nixpkgs/nixos-23.05"; -nixpkgs-unstable.url = "nixpkgs/nixos-unstable"; + inputs = { + secrets = { + url = "git+ssh://forgejo@git.toast003.xyz:4222/Toast/nix-secrets"; + flake = false; + }; + nixpkgs-raw.url = "nixpkgs/nixos-25.11"; + nixpkgs-unstable-raw.url = "nixpkgs/nixos-unstable"; -agenix = { - url = "github:ryantm/agenix"; - inputs = { - nixpkgs.follows = "nixpkgs"; - darwin.follows = ""; # Not using this on MacOS, so this doesn't pull it's dependencies - }; -}; + agenix = { + url = "github:ryantm/agenix"; + inputs = { + nixpkgs.follows = "nixpkgs-raw"; + darwin.follows = ""; # Not using this on MacOS, so this doesn't pull it's dependencies + }; + }; + sops-nix = { + url = "github:Mic92/sops-nix"; + inputs.nixpkgs.follows = "nixpkgs-unstable-raw"; + }; -home-manager.url = "github:nix-community/home-manager/release-23.05"; -home-manager.inputs.nixpkgs.follows = "nixpkgs"; + home-manager = { + url = "github:nix-community/home-manager/release-25.11"; + inputs.nixpkgs.follows = "nixpkgs-raw"; + }; -nix-impermanence.url = "github:nix-community/impermanence"; -}; + home-manager-unstable = { + url = "github:nix-community/home-manager/"; + inputs.nixpkgs.follows = "nixpkgs-unstable-raw"; + }; -outputs = {nixpkgs, agenix, home-manager, nixpkgs-unstable, nix-impermanence, ... }: { - devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShell { - name = "Environment for toast's nixos configurations"; - # The agenix cli is not needed to activate a configuration, so instead of installing it - # I'll just add it to de devShell, since that's the only real time I'm going to use it. - packages = [ - agenix.packages.x86_64-linux.default - nixpkgs.legacyPackages.x86_64-linux.git - ]; - shellHook ='' - export PS1="$PS1(toast-configs)> " - ''; - }; + nixos-hardware.url = "github:NixOS/nixos-hardware/master"; - nixosConfigurations = { - Archie = nixpkgs-unstable.lib.nixosSystem { - system = "x86_64-linux"; - pkgs = import nixpkgs-unstable { - system = "x86_64-linux"; - config = { allowUnfree = true; }; # TODO: Find why this doesn't work - overlays = - let - discordOverlay = self: super: { - discord = super.discord.override { - withOpenASAR = true; - withVencord = true; - }; - }; - in - [ discordOverlay ]; - }; - modules = [ - # Needed for nix-index - { nix.nixPath = [ "nixpkgs=${nixpkgs}" ]; } - agenix.nixosModules.default - home-manager.nixosModule - ./roles/common - ./roles/desktop - ./roles/kde - ./machines/Archie - ]; - }; + jovian = { + url = "github:Jovian-Experiments/Jovian-NixOS"; + inputs.nixpkgs.follows = "nixpkgs-unstable-raw"; + }; - Everest = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - pkgs = import nixpkgs { - system = "x86_64-linux"; - config = { allowUnfree = false; }; # TODO: Find why this doesn't work - }; - modules = [ - # Needed for nix-index - { nix.nixPath = [ "nixpkgs=${nixpkgs}" ]; } - agenix.nixosModules.default - home-manager.nixosModule - ./roles/common - ./roles/server - ./machines/Everest - ./openbox.nix - ]; - }; - }; -}; + nix-impermanence.url = "github:nix-community/impermanence"; + + copyparty.url = "github:9001/copyparty"; + copyparty.inputs.nixpkgs.follows = "nixpkgs-raw"; + + /* + These are the same input, just following different nixpkgs versions + This avoids some wierdness when using one that follows unstable on a stable nixpkgs + */ + nix-index-db = { + url = "github:Mic92/nix-index-database"; + inputs.nixpkgs.follows = "nixpkgs-raw"; + }; + + nix-index-db-unstable = { + url = "github:Mic92/nix-index-database"; + inputs.nixpkgs.follows = "nixpkgs-unstable-raw"; + }; + + plasma-manager = { + url = "github:nix-community/plasma-manager/"; + inputs.nixpkgs.follows = "nixpkgs-unstable-raw"; + inputs.home-manager.follows = "home-manager-unstable"; + }; + + nix-flatpak.url = "github:gmodena/nix-flatpak/main"; + + catppuccin.url = "github:catppuccin/nix"; + + lix-module = { + url = "https://git.lix.systems/lix-project/nixos-module/archive/main.tar.gz"; + inputs.nixpkgs.follows = "nixpkgs-raw"; + inputs.lix.follows = "lix"; + }; + lix-module-unstable = { + url = "https://git.lix.systems/lix-project/nixos-module/archive/main.tar.gz"; + inputs.nixpkgs.follows = "nixpkgs-unstable-raw"; + inputs.lix.follows = "lix"; + }; + + # Non flake inputs / random things + lix = { + url = "https://git.lix.systems/lix-project/lix/archive/main.tar.gz"; + flake = false; + }; + + eza-themes = { + url = "github:eza-community/eza-themes"; + flake = false; + }; + + catppuccin-konsole = { + url = "github:catppuccin/konsole"; + flake = false; + }; + }; + + outputs = {...} @ inputs: + with inputs; + # Patch nixpkgs + # https://ertt.ca/nix/patch-nixpkgs/ + let + nixpkgs-unstable-patched = nixpkgs-raw.legacyPackages.x86_64-linux.applyPatches { + name = "patched-nixpkgs-unstable"; + src = nixpkgs-unstable-raw; + patches = [ + ./nixpkgs-patches/pr471291.patch + ./nixpkgs-patches/revert-mangohud-update.patch + ]; + }; + nixpkgs-patched = nixpkgs-raw.legacyPackages.x86_64-linux.applyPatches { + name = "patched-nixpkgs"; + src = nixpkgs-raw; + patches = [ + ]; + }; + # https://discourse.nixos.org/t/proper-way-of-applying-patch-to-system-managed-via-flake/21073/26 + nixpkgs-unstable = (import "${nixpkgs-unstable-patched}/flake.nix").outputs {self = inputs.nixpkgs-unstable-raw;}; + nixpkgs = (import "${nixpkgs-patched}/flake.nix").outputs {self = inputs.nixpkgs-raw;}; + in { + formatter.x86_64-linux = nixpkgs-unstable.legacyPackages.x86_64-linux.alejandra; + devShells.x86_64-linux.default = nixpkgs.legacyPackages.x86_64-linux.mkShellNoCC { + name = "toast-devshell"; + # The agenix cli is not needed to activate a configuration, so instead of installing it + # I'll just add it to de devShell, since that's the only real time I'm going to use it. + packages = with nixpkgs.legacyPackages.x86_64-linux; [ + agenix.packages.x86_64-linux.default + git + nvd + nix-output-monitor + nix-diff + just + alejandra + ]; + shellHook = '' + export PS1="$PS1(toast-configs)> " + ''; + }; + overlays.default = final: prev: { + kasane-teto-cursor = final.callPackage ./pkgs/kasane-teto-cursor {}; + kame-editor = final.callPackage ./pkgs/kame-editor {}; + kame-tools = final.callPackage ./pkgs/kame-tools {}; + rstmcpp = final.callPackage ./pkgs/rstmcpp {}; + }; + packages = { + x86_64-linux = with import nixpkgs-unstable-raw { + system = "x86_64-linux"; + overlays = [self.overlays.default]; + }; { + inherit kasane-teto-cursor kame-editor kame-tools rstmcpp; + }; + }; + nixosConfigurations = let + mkSystems = hosts: + builtins.mapAttrs ( + host: settings: let + pkgs = + if isStable + then nixpkgs + else nixpkgs-unstable; + isStable = (settings ? stable) && (settings.stable == true); + in + mkSystem host settings.modules pkgs isStable + ) + hosts; + mkSystem = host: modules: pkgs: stable: + pkgs.lib.nixosSystem { + system = "x86_64-linux"; + specialArgs = { + flakeSelf = self; + }; + lib = import ./lib {nixpkgs = pkgs;}; + modules = + [ + agenix.nixosModules.default + sops-nix.nixosModules.sops + ( + if stable + then home-manager + else home-manager-unstable + ) + .nixosModules + .home-manager + ( + if stable + then nix-index-db + else nix-index-db-unstable + ) + .nixosModules + .nix-index + catppuccin.nixosModules.catppuccin + ( + if stable + then lix-module + else lix-module-unstable + ) + .nixosModules + .default + ./roles/common + (./machines + "/${host}") + ] + ++ modules; + }; + in + mkSystems { + Archie.modules = [ + nixos-hardware.nixosModules.common-cpu-amd-zenpower + ./roles/desktop + ./roles/kde + ./roles/gaming + ]; + SurfaceGo.modules = [ + nixos-hardware.nixosModules.microsoft-surface-go + ./roles/desktop + ./roles/kde + ./machines/SurfaceGo + ]; + SteamDeck.modules = [ + jovian.nixosModules.default + ./roles/desktop + ./roles/kde + ./roles/gaming + ]; + WinMax2.modules = [ + nixos-hardware.nixosModules.gpd-win-max-2-2023 + ./roles/desktop + ./roles/kde + ./roles/gaming + ]; + Everest = { + stable = true; + modules = [ + copyparty.nixosModules.default + ./roles/server + ]; + }; + }; + }; } diff --git a/lib/default.nix b/lib/default.nix new file mode 100644 index 0000000..67bfe4e --- /dev/null +++ b/lib/default.nix @@ -0,0 +1,10 @@ +{nixpkgs}: +nixpkgs.lib.extend (final: prev: { + toast = let + importLib = file: import file {lib = final;}; + in { + patches = importLib ./patches.nix; + networkManager = importLib ./networkManager.nix; + syncthing = importLib ./syncthing.nix; + }; +}) diff --git a/lib/networkManager.nix b/lib/networkManager.nix new file mode 100644 index 0000000..b4bfbfe --- /dev/null +++ b/lib/networkManager.nix @@ -0,0 +1,23 @@ +{lib}: { + /** + Make a NetworkManager wifi profile, to be used with ensureProfiles + */ + mkWifiProfile = { + id, + ssid, + priority ? 0, + wifi-security, + }: { + connection = { + inherit id; + type = "wifi"; + autoconnect-priority = priority; + }; + ipv4.method = "auto"; + wifi = { + mode = "infrastructure"; + inherit ssid; + }; + inherit wifi-security; + }; +} diff --git a/lib/patches.nix b/lib/patches.nix new file mode 100644 index 0000000..4f3c1a8 --- /dev/null +++ b/lib/patches.nix @@ -0,0 +1,13 @@ +{lib}: { + /** + Get a list of patches from a path. + */ + patchesInPath = path: let + pathContents = builtins.readDir path; + filter = name: value: + (value == "regular" || value == "symlink") && lib.strings.hasSuffix ".patch" name; + filteredContents = lib.attrsets.filterAttrs filter pathContents; + patchFilenames = builtins.attrNames filteredContents; + in + builtins.map (value: lib.path.append path value) patchFilenames; +} diff --git a/lib/syncthing.nix b/lib/syncthing.nix new file mode 100644 index 0000000..90553b4 --- /dev/null +++ b/lib/syncthing.nix @@ -0,0 +1,5 @@ +{lib}: let + data = import ./../syncthing.nix; +in { + devices = builtins.getAttr "devices" data; +} diff --git a/machines/Archie/configuration.nix b/machines/Archie/configuration.nix index f91582d..e35d1fe 100644 --- a/machines/Archie/configuration.nix +++ b/machines/Archie/configuration.nix @@ -1,105 +1,79 @@ # Edit this configuration file to define what should be installed on # your system. Help is available in the configuration.nix(5) man page # and in the NixOS manual (accessible by running `nixos-help`). - -{ config, pkgs, ... }: - { - # Use grub boot loader - boot.loader = { - systemd-boot.enable = false; - grub = { - enable = true; - device = "nodev"; - efiSupport = true; - useOSProber = true; - }; - efi.efiSysMountPoint = "/boot/efi"; - }; - boot.loader.efi.canTouchEfiVariables = true; + config, + pkgs, + ... +}: { + # Use grub boot loader + boot.loader = { + systemd-boot.enable = false; + grub = { + enable = true; + device = "nodev"; + efiSupport = true; + useOSProber = true; + }; + efi.efiSysMountPoint = "/boot/efi"; + }; + boot.loader.efi.canTouchEfiVariables = true; - networking.hostName = "Archie"; # Define your hostname. - networking.networkmanager.enable = true; # Enable networking + boot.kernelPackages = pkgs.linuxKernel.packages.linux_xanmod_latest; - # Allow unfree packages - nixpkgs.config.allowUnfree = true; + networking.hostName = "Archie"; # Define your hostname. - # Set your time zone. - time.timeZone = "Europe/Madrid"; + # Allow unfree packages + nixpkgs.config.allowUnfree = true; - # Configure network proxy if necessary - # networking.proxy.default = "http://user:password@proxy:port/"; - # networking.proxy.noProxy = "127.0.0.1,localhost,internal.domain"; + # Configure network proxy if necessary + # networking.proxy.default = "http://user:password@proxy:port/"; + # networking.proxy.noProxy = "127.0.0.1,localhost,internal.domain"; - # Select internationalisation properties. - i18n.defaultLocale = "en_US.UTF-8"; - console = { - keyMap = "es"; - }; + # Configure keymap in X11 + # services.xserver.layout = "us"; + # services.xserver.xkbOptions = "eurosign:e,caps:escape"; - # Enable the X11 windowing system. - services.xserver.enable = true; + # Enable CUPS to print documents. + # services.printing.enable = true; - # Enable the pipewire sound server - services.pipewire = { - enable = true; - pulse.enable = true; - }; + # Enable sound. + # sound.enable = true; + # hardware.pulseaudio.enable = true; - # Configure keymap in X11 - # services.xserver.layout = "us"; - # services.xserver.xkbOptions = "eurosign:e,caps:escape"; + # Enable touchpad support (enabled default in most desktopManager). + # services.xserver.libinput.enable = true; - # Enable CUPS to print documents. - # services.printing.enable = true; + hardware.bluetooth.enable = true; - # Enable sound. - # sound.enable = true; - # hardware.pulseaudio.enable = true; + # List packages installed in system profile. To search, run: + # $ nix search wget + # environment.systemPackages = with pkgs; [ + # vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default. + # wget + # ]; - # Enable touchpad support (enabled default in most desktopManager). - # services.xserver.libinput.enable = true; + # Some programs need SUID wrappers, can be configured further or are + # started in user sessions. + # programs.mtr.enable = true; + # programs.gnupg.agent = { + # enable = true; + # enableSSHSupport = true; + # }; - # Define a user account. Don't forget to set a password with ‘passwd’. - users.users.toast = { - isNormalUser = true; - extraGroups = [ "wheel" ]; # Enable ‘sudo’ for the user. - packages = with pkgs; [ - firefox - tree - ]; - }; + # List services that you want to enable: - # List packages installed in system profile. To search, run: - # $ nix search wget - # environment.systemPackages = with pkgs; [ - # vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default. - # wget - # ]; + # Enable the OpenSSH daemon. + # services.openssh.enable = true; - # Some programs need SUID wrappers, can be configured further or are - # started in user sessions. - # programs.mtr.enable = true; - # programs.gnupg.agent = { - # enable = true; - # enableSSHSupport = true; - # }; - - # List services that you want to enable: - - # Enable the OpenSSH daemon. - # services.openssh.enable = true; - - # Open ports in the firewall. - # networking.firewall.allowedTCPPorts = [ ... ]; - # networking.firewall.allowedUDPPorts = [ ... ]; - # Or disable the firewall altogether. - # networking.firewall.enable = false; - - # Copy the NixOS configuration file and link it from the resulting system - # (/run/current-system/configuration.nix). This is useful in case you - # accidentally delete configuration.nix. - # system.copySystemConfiguration = true; + # Open ports in the firewall. + # networking.firewall.allowedTCPPorts = [ ... ]; + # networking.firewall.allowedUDPPorts = [ ... ]; + # Or disable the firewall altogether. + # networking.firewall.enable = false; + # Copy the NixOS configuration file and link it from the resulting system + # (/run/current-system/configuration.nix). This is useful in case you + # accidentally delete configuration.nix. + # system.copySystemConfiguration = true; } - diff --git a/machines/Archie/default.nix b/machines/Archie/default.nix index 5bf2485..187a110 100755 --- a/machines/Archie/default.nix +++ b/machines/Archie/default.nix @@ -1,8 +1,6 @@ -{ ... }: - -{ - imports = [ - ./configuration.nix - ./hardware-configuration.nix - ]; +{...}: { + imports = [ + ./configuration.nix + ./hardware-configuration.nix + ]; } diff --git a/machines/Archie/hardware-configuration.nix b/machines/Archie/hardware-configuration.nix index cd626b4..cb7bd98 100644 --- a/machines/Archie/hardware-configuration.nix +++ b/machines/Archie/hardware-configuration.nix @@ -1,43 +1,86 @@ # Do not modify this file! It was generated by ‘nixos-generate-config’ # and may be overwritten by future invocations. Please make changes # to /etc/nixos/configuration.nix instead. -{ config, lib, pkgs, modulesPath, ... }: - { - imports = - [ (modulesPath + "/installer/scan/not-detected.nix") - ]; + config, + lib, + modulesPath, + ... +}: { + imports = [ + (modulesPath + "/installer/scan/not-detected.nix") + ]; - boot.initrd.availableKernelModules = [ "xhci_pci" "ahci" "usbhid" "usb_storage" "sd_mod" ]; - boot.initrd.kernelModules = [ ]; - boot.kernelModules = [ "kvm-amd" ]; - boot.extraModulePackages = [ ]; + # Enable support for the Xbox One wireless dongle + hardware.xone.enable = true; - fileSystems."/" = - { device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; - fsType = "btrfs"; - options = [ "subvol=@root" "compress=zstd" ]; - }; + boot.initrd.availableKernelModules = ["xhci_pci" "ahci" "usbhid" "nvme" "sd_mod"]; + boot.initrd.kernelModules = ["amdgpu"]; + boot.kernelModules = ["kvm-amd"]; + boot.extraModulePackages = []; + boot.extraModprobeConfig = "options snd_hda_intel power_save=0"; - fileSystems."/nix" = - { device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; - fsType = "btrfs"; - options = [ "subvol=@nix" "compress=zstd" ]; - }; + fileSystems."/" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=@root"]; + }; - fileSystems."/boot" = - { device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; - fsType = "btrfs"; - options = [ "subvol=@boot" "compress=zstd" ]; - }; + fileSystems."/nix" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=@nix"]; + }; - fileSystems."/boot/efi" = - { device = "/dev/disk/by-uuid/FB87-4CBC"; - fsType = "vfat"; - }; + fileSystems."/boot" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=@boot"]; + }; - swapDevices = [ ]; + fileSystems."/boot/efi" = { + device = "/dev/disk/by-uuid/FB87-4CBC"; + fsType = "vfat"; + }; - nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; + fileSystems = { + /* + Mount the root subvolume of the SSD + This is helpful for getting things from + my old Arch install, as well as for running btdu + */ + "/mnt/ssd" = { + device = config.fileSystems."/".device; + fsType = config.fileSystems."/".fsType; + options = ["subvolid=5" "ro"]; + }; + "/mnt/hdd" = { + # device = "/dev/disk/by-id/ata-SAMSUNG_HD103SI_S1Y5J9CZA19763-part1"; + label = "Archie\\x20HDD"; + fsType = "bcachefs"; + options = ["x-systemd.automount"]; + }; + "/mnt/windows" = { + device = "/dev/disk/by-uuid/B61AFDAC1AFD6A2F"; + fsType = "ntfs3"; + neededForBoot = false; + options = ["noauto" "windows_names"]; + }; + "/home" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=@home"]; + }; + "/persist" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=@persist"]; + neededForBoot = true; + }; + }; + + swapDevices = []; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; } diff --git a/machines/Everest/configuration.nix b/machines/Everest/configuration.nix index 5b6d2fe..4db3273 100755 --- a/machines/Everest/configuration.nix +++ b/machines/Everest/configuration.nix @@ -1,103 +1,60 @@ # Edit this configuration file to define what should be installed on # your system. Help is available in the configuration.nix(5) man page # and in the NixOS manual (accessible by running ‘nixos-help’). +{lib, ...}: { + # Bootloader. + boot.loader.systemd-boot.enable = true; + boot.loader.timeout = 5; + boot.loader.efi.canTouchEfiVariables = true; + boot.loader.efi.efiSysMountPoint = "/boot/efi"; -{ config, pkgs, lib, ... }: + # I'm using Nix OS, it's logo is a snowflake and the computer is + # a lot taller than the pi it's replacing, so Everest! :3 :3 + networking.hostName = "Everest"; # Define your hostname. -{ - # Bootloader. - boot.loader.systemd-boot.enable = true; - boot.loader.timeout = 5; - boot.loader.efi.canTouchEfiVariables = true; - boot.loader.efi.efiSysMountPoint = "/boot/efi"; + # Set up networking + networking = { + wireless.enable = false; # Computer doesn't have wifi + enableIPv6 = false; + useNetworkd = true; + dhcpcd.enable = false; + interfaces.eno1 = { + wakeOnLan.enable = true; + ipv4.addresses = [ + { + address = "192.168.1.160"; + prefixLength = 24; + } + ]; + }; + # I use networkd, so I need to declare the interface for the default gateway + defaultGateway = { + address = "192.168.1.1"; + interface = "eno1"; + }; + nameservers = ["9.9.9.9"]; + }; + systemd.network.wait-online.extraArgs = ["--dns"]; - # I'm using Nix OS, it's logo is a snowflake and the computer is - # a lot taller than the pi it's replacing, so Everest! :3 :3 - networking.hostName = "Everest"; # Define your hostname. + time.timeZone = "Europe/Madrid"; + services.automatic-timezoned.enable = lib.mkForce false; - # Set up networking - networking = { - wireless.enable = false; # Computer doesn't have wifi - enableIPv6 = false; - useNetworkd = true; - dhcpcd.enable = false; - interfaces.eno1 = { - wakeOnLan.enable = true; - ipv4.addresses = [ { - address = "192.168.0.160"; - prefixLength = 24; - } ]; - }; - defaultGateway = "192.168.0.1"; - nameservers = [ "8.8.8.8" ]; - }; + # Define a user account. Don't forget to set a password with ‘passwd’. + users.users.toast = { + extraGroups = ["networkmanager" "transmission"]; + }; - # Set your time zone. - time.timeZone = "Europe/Madrid"; + # Large builds (the linux kernel) fail to build because /tmp is too small when using tmpfs + boot.tmp.useTmpfs = false; - # Select internationalisation properties. - i18n.defaultLocale = "en_US.UTF-8"; - - i18n.extraLocaleSettings = { - LC_ADDRESS = "es_ES.UTF-8"; - LC_IDENTIFICATION = "es_ES.UTF-8"; - LC_MEASUREMENT = "es_ES.UTF-8"; - LC_MONETARY = "es_ES.UTF-8"; - LC_NAME = "es_ES.UTF-8"; - LC_NUMERIC = "es_ES.UTF-8"; - LC_PAPER = "es_ES.UTF-8"; - LC_TELEPHONE = "es_ES.UTF-8"; - LC_TIME = "es_ES.UTF-8"; - }; - - # Configure keymap in X11 - services.xserver = { - layout = "es"; - xkbVariant = ""; - }; - - # Configure console keymap - console.keyMap = "es"; - - # Define a user account. Don't forget to set a password with ‘passwd’. - users.users.toast = { - isNormalUser = true; - description = "Toast"; - extraGroups = [ "networkmanager" "wheel" "transmission"]; - packages = with pkgs; []; - openssh.authorizedKeys.keys = [ - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2bOVmxUNvg9qFv9DlzMmTRlzcNsyNq1F1wBuAXySwsWAzHGaO+WGdSCINxW3k2ccXn7M/o1r89LeTzRzi8sWQYCpBaIqYVszM/r7SvTS4gASyKhM6lNlyUEPOnvCXH7rdtF+fjoA1TJPv7GBk78QRhGh+eVO3qhY1m++5C1CPFlyrc6sSfgIBQJ5GQZFl/7YEgsrPo+M+0Sd7LkaCOyNmJA0Wi0BA3bbf5sJhrZVMMg/p7w+eMphz2kd1VTVjW3yeMq9zLCiu4SOTBNGCMEvKIdUZbQ83lNrqO2z1/3T1bDwJgpz3xusfkNCeNJSmhfFw5ydHEUp/9jshq38WmulKAMw2Kl/Zed62AVU7Ux7YjUkZkWvo8i3eXuLUxoG891S7cWV1/ijs9QMajOLLT14FG7RbzUYYaYlx+/iNGji9d4sp9/oMYyO45TMe+vEezFSBygP7TY0QFOr4xTi49ZRQFsszbFnGRv+k3wVKoGoeNt0xWB8pBEPFtaeHJpQyJX8= id_rsa_moon" - "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOeu3crGqtxwaqgoQPt5mWlC8+PL/Icvcvo0MBAaK80L Key for work laptop" - ]; - }; - - home-manager = { - backupFileExtension = "backup"; - useGlobalPkgs = true; - users.toast = { config, ... }: { - home = { - stateVersion = "23.05"; - file = { - # This symlinks the Transmission downloads folder into my user's downloads folder for easy access - "Downloads/Transmission".source = config.lib.file.mkOutOfStoreSymlink "/var/lib/transmission/Downloads"; - }; - }; - xdg = { - #enable = true; - userDirs = { - enable = true; - createDirectories = true; - publicShare = null; # Disable the public folder - }; - }; - }; - }; - - # Open ports in the firewall. - # 8384 is syncthing's webui, and 22000 is syncthing related too - # No idea what 5201 and 21027 do tho - networking.firewall.allowedTCPPorts = [ 5201 8384 22000 ]; - networking.firewall.allowedUDPPorts = [ 5201 22000 21027]; - # Or disable the firewall altogether. - # networking.firewall.enable = false; + home-manager = { + users.toast = {config, ...}: { + home = { + file = { + # This symlinks the Transmission downloads folder into my user's downloads folder for easy access + "Downloads/Transmission".source = config.lib.file.mkOutOfStoreSymlink "/var/lib/transmission/Downloads"; + }; + }; + }; + }; } diff --git a/machines/Everest/default.nix b/machines/Everest/default.nix index 5bf2485..187a110 100755 --- a/machines/Everest/default.nix +++ b/machines/Everest/default.nix @@ -1,8 +1,6 @@ -{ ... }: - -{ - imports = [ - ./configuration.nix - ./hardware-configuration.nix - ]; +{...}: { + imports = [ + ./configuration.nix + ./hardware-configuration.nix + ]; } diff --git a/machines/Everest/hardware-configuration.nix b/machines/Everest/hardware-configuration.nix index 528f3ba..5ccf3d7 100755 --- a/machines/Everest/hardware-configuration.nix +++ b/machines/Everest/hardware-configuration.nix @@ -1,37 +1,69 @@ # Do not modify this file! It was generated by ‘nixos-generate-config’ # and may be overwritten by future invocations. Please make changes # to /etc/nixos/configuration.nix instead. -{ config, lib, pkgs, modulesPath, ... }: - { - imports = - [ (modulesPath + "/installer/scan/not-detected.nix") - ]; + config, + lib, + modulesPath, + ... +}: { + imports = [ + (modulesPath + "/installer/scan/not-detected.nix") + ]; - boot.initrd.availableKernelModules = [ "xhci_pci" "ehci_pci" "ahci" "usbhid" "usb_storage" "sd_mod" ]; - boot.initrd.kernelModules = [ ]; - boot.kernelModules = [ "kvm-intel" ]; - boot.extraModulePackages = [ ]; + boot.initrd.availableKernelModules = ["xhci_pci" "ehci_pci" "ahci" "usbhid" "usb_storage" "sd_mod"]; + boot.initrd.kernelModules = []; + boot.kernelModules = ["kvm-intel"]; + boot.extraModulePackages = []; - fileSystems."/" = - { device = "/dev/disk/by-label/Everest"; - fsType = "btrfs"; - }; + fileSystems = { + "/" = { + device = "/dev/disk/by-label/Everest"; + fsType = "btrfs"; + options = ["compress=zstd" "subvol=@"]; + }; + "/nix" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=@nix-server"]; + }; + "/home" = { + device = "/dev/disk/by-label/Everest"; + fsType = "btrfs"; + options = ["compress=zstd" "subvol=@home"]; + }; + "/mnt/hdd" = { + device = "/dev/disk/by-label/Everest"; + fsType = "btrfs"; + options = ["compress=zstd" "subvol=/" "ro"]; + }; + "/mnt/ssd" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=/" "ro"]; + }; + "/persist" = { + device = "/dev/disk/by-label/Everest"; + fsType = "btrfs"; + options = ["compress=zstd" "subvol=@persist"]; + neededForBoot = true; + }; + }; - fileSystems."/boot/efi" = - { device = "/dev/disk/by-label/Boot"; - fsType = "vfat"; - }; + fileSystems."/boot/efi" = { + device = "/dev/disk/by-uuid/FB87-4CBC"; + fsType = "vfat"; + }; - swapDevices = [ ]; + swapDevices = []; - # Enables DHCP on each ethernet and wireless interface. In case of scripted networking - # (the default) this is the recommended approach. When using systemd-networkd it's - # still possible to use this option, but it's recommended to use it in conjunction - # with explicit per-interface declarations with `networking.interfaces..useDHCP`. - networking.useDHCP = lib.mkDefault true; - # networking.interfaces.eno1.useDHCP = lib.mkDefault true; + # Enables DHCP on each ethernet and wireless interface. In case of scripted networking + # (the default) this is the recommended approach. When using systemd-networkd it's + # still possible to use this option, but it's recommended to use it in conjunction + # with explicit per-interface declarations with `networking.interfaces..useDHCP`. + networking.useDHCP = lib.mkDefault true; + # networking.interfaces.eno1.useDHCP = lib.mkDefault true; - nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; - hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; } diff --git a/machines/SteamDeck/configuration.nix b/machines/SteamDeck/configuration.nix new file mode 100755 index 0000000..c14385e --- /dev/null +++ b/machines/SteamDeck/configuration.nix @@ -0,0 +1,103 @@ +# Edit this configuration file to define what should be installed on +# your system. Help is available in the configuration.nix(5) man page +# and in the NixOS manual (accessible by running `nixos-help`). +{ + config, + pkgs, + lib, + ... +}: { + # Use grub boot loader + boot.loader = { + systemd-boot.enable = false; + grub = { + enable = true; + device = "nodev"; + efiSupport = true; + # No other OS on here :P + useOSProber = false; + }; + efi.efiSysMountPoint = config.fileSystems."efi_boot_partition".mountPoint; + }; + boot.loader.efi.canTouchEfiVariables = true; + + networking.hostName = "SteamDeck"; # Define your hostname. + + # Allow unfree packages + nixpkgs.config.allowUnfree = true; + + # Configure network proxy if necessary + # networking.proxy.default = "http://user:password@proxy:port/"; + # networking.proxy.noProxy = "127.0.0.1,localhost,internal.domain"; + + jovian = { + devices.steamdeck = { + enable = true; + }; + # Steam Deck UI settings + steam = { + enable = true; + autoStart = true; + user = "toast"; + desktopSession = "plasma"; + }; + decky-loader = { + enable = true; + }; + }; + services.displayManager.sddm.enable = lib.mkForce false; + + # Enable bluetooth + hardware.bluetooth = { + enable = true; + }; + + # Configure keymap in X11 + # services.xserver.layout = "us"; + # services.xserver.xkbOptions = "eurosign:e,caps:escape"; + + # Enable CUPS to print documents. + # services.printing.enable = true; + + # Enable sound. + # sound.enable = true; + # hardware.pulseaudio.enable = true; + + # Enable touchpad support (enabled default in most desktopManager). + # services.xserver.libinput.enable = true; + + # Large builds (the linux kernel) fail to build because /tmp is too small when using tmpfs + boot.tmp.useTmpfs = false; + + environment.systemPackages = [pkgs.steamdeck-firmware pkgs.steamdeck-hw-theme]; + # List packages installed in system profile. To search, run: + # $ nix search wget + # environment.systemPackages = with pkgs; [ + # vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default. + # wget + # ]; + + # Some programs need SUID wrappers, can be configured further or are + # started in user sessions. + # programs.mtr.enable = true; + # programs.gnupg.agent = { + # enable = true; + # enableSSHSupport = true; + # }; + + # List services that you want to enable: + + # Enable the OpenSSH daemon. + # services.openssh.enable = true; + + # Open ports in the firewall. + # networking.firewall.allowedTCPPorts = [ ... ]; + # networking.firewall.allowedUDPPorts = [ ... ]; + # Or disable the firewall altogether. + # networking.firewall.enable = false; + + # Copy the NixOS configuration file and link it from the resulting system + # (/run/current-system/configuration.nix). This is useful in case you + # accidentally delete configuration.nix. + # system.copySystemConfiguration = true; +} diff --git a/machines/SteamDeck/default.nix b/machines/SteamDeck/default.nix new file mode 100755 index 0000000..187a110 --- /dev/null +++ b/machines/SteamDeck/default.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./configuration.nix + ./hardware-configuration.nix + ]; +} diff --git a/machines/SteamDeck/hardware-configuration.nix b/machines/SteamDeck/hardware-configuration.nix new file mode 100755 index 0000000..4ade12c --- /dev/null +++ b/machines/SteamDeck/hardware-configuration.nix @@ -0,0 +1,79 @@ +# Do not modify this file! It was generated by ‘nixos-generate-config’ +# and may be overwritten by future invocations. Please make changes +# to /etc/nixos/configuration.nix instead. +{ + config, + lib, + modulesPath, + ... +}: let + # \x20 is the escape code for a space + ssdLabel = ''Deck\\x20SSD''; +in { + imports = [ + (modulesPath + "/installer/scan/not-detected.nix") + ]; + + # Enable support for the Xbox One wireless dongle + hardware.xone.enable = true; + + boot.initrd.availableKernelModules = ["nvme" "xhci_pci" "usb_storage" "usbhid" "sd_mod" "sdhci_pci"]; + boot.initrd.kernelModules = []; + boot.kernelModules = ["kvm-amd"]; + boot.extraModulePackages = []; + + fileSystems = { + "efi_boot_partition" = { + mountPoint = "/boot/efi"; + label = "deckboot"; + fsType = "vfat"; + }; + /* + Mount the root subvolume of the SSD + This is helpful for getting things from + my old Arch install, as well as for running btdu + */ + "btrfs_root_subvolume" = { + mountPoint = "/mnt/ssd"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvolid=5" "ro"]; + }; + "btrfs_root" = { + mountPoint = "/"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@" "compress=zstd"]; + }; + "btrfs_persist" = { + mountPoint = "/persist"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@persist"]; + neededForBoot = true; + }; + "btrfs_boot" = { + mountPoint = "/boot"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@boot" "compress=zstd"]; + }; + "btrfs_home" = { + mountPoint = "/home"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@home" "compress=zstd"]; + }; + "btrfs_nix" = { + mountPoint = "/nix"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@nix" "compress=zstd"]; + }; + }; + + swapDevices = []; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + hardware.cpu.amd.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; +} diff --git a/machines/SurfaceGo/configuration.nix b/machines/SurfaceGo/configuration.nix new file mode 100644 index 0000000..49c0620 --- /dev/null +++ b/machines/SurfaceGo/configuration.nix @@ -0,0 +1,92 @@ +# Edit this configuration file to define what should be installed on +# your system. Help is available in the configuration.nix(5) man page +# and in the NixOS manual (accessible by running `nixos-help`). +{ + config, + pkgs, + lib, + ... +}: { + boot = { + loader = { + # Use grub boot loader + systemd-boot.enable = false; + grub = { + enable = true; + device = "nodev"; + efiSupport = true; + enableCryptodisk = true; + }; + efi = { + efiSysMountPoint = "/boot/efi"; + canTouchEfiVariables = true; + }; + }; + # I need systemd for tpm luks unlocking + initrd.systemd.enable = true; + }; + + security.tpm2.enable = true; + + networking.hostName = "SurfaceGo"; # Define your hostname. + + # Allow unfree packages + nixpkgs.config.allowUnfree = true; + + # Configure network proxy if necessary + # networking.proxy.default = "http://user:password@proxy:port/"; + # networking.proxy.noProxy = "127.0.0.1,localhost,internal.domain"; + + # Configure keymap in X11 + # services.xserver.layout = "us"; + # services.xserver.xkbOptions = "eurosign:e,caps:escape"; + + console = { + # The kernel doesn't detect the scree as being HiDPI, so I need to use a bigger font + font = "ter-i32n"; + }; + + # Enable CUPS to print documents. + # services.printing.enable = true; + + # Enable sound. + # sound.enable = true; + # hardware.pulseaudio.enable = true; + + # Enable touchpad support (enabled default in most desktopManager). + # services.xserver.libinput.enable = true; + + # List packages installed in system profile. To search, run: + # $ nix search wget + # environment.systemPackages = with pkgs; [ + # vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default. + # wget + # ]; + + # The surface kernel sometimes fails to suspend/shutdown and I got tired of fighting it + boot.kernelPackages = lib.mkForce pkgs.linuxPackages; + + # Some programs need SUID wrappers, can be configured further or are + # started in user sessions. + # programs.mtr.enable = true; + # programs.gnupg.agent = { + # enable = true; + # enableSSHSupport = true; + # }; + + # List services that you want to enable: + + # Enable the OpenSSH daemon. + # services.openssh.enable = true; + + # Open ports in the firewall. + # networking.firewall.allowedTCPPorts = [ ... ]; + # networking.firewall.allowedUDPPorts = [ ... ]; + # Or disable the firewall altogether. + # networking.firewall.enable = false; + + # Copy the NixOS configuration file and link it from the resulting system + # (/run/current-system/configuration.nix). This is useful in case you + # accidentally delete configuration.nix. + # system.copySystemConfiguration = true; +} diff --git a/machines/SurfaceGo/default.nix b/machines/SurfaceGo/default.nix new file mode 100644 index 0000000..187a110 --- /dev/null +++ b/machines/SurfaceGo/default.nix @@ -0,0 +1,6 @@ +{...}: { + imports = [ + ./configuration.nix + ./hardware-configuration.nix + ]; +} diff --git a/machines/SurfaceGo/hardware-configuration.nix b/machines/SurfaceGo/hardware-configuration.nix new file mode 100644 index 0000000..81a4c56 --- /dev/null +++ b/machines/SurfaceGo/hardware-configuration.nix @@ -0,0 +1,50 @@ +# Do not modify this file! It was generated by ‘nixos-generate-config’ +# and may be overwritten by future invocations. Please make changes +# to /etc/nixos/configuration.nix instead. +{ + config, + lib, + ... +}: { + boot.initrd.availableKernelModules = ["xhci_pci" "nvme" "usbhid" "rtsx_pci_sdmmc"]; + boot.initrd.kernelModules = []; + boot.kernelModules = ["kvm-intel"]; + boot.extraModulePackages = []; + + boot.initrd.luks.devices."SSD".device = "/dev/disk/by-uuid/1d8d7578-d3a1-4ea0-90ad-4257266a6caf"; + + fileSystems."/" = { + device = "/dev/disk/by-uuid/19a52b40-3ff6-47ff-9402-18d8b289643e"; + fsType = "btrfs"; + options = ["subvol=@" "compress=zstd"]; + }; + + fileSystems."/boot" = { + device = "/dev/disk/by-uuid/19a52b40-3ff6-47ff-9402-18d8b289643e"; + fsType = "btrfs"; + options = ["subvol=@boot" "compress=zstd"]; + }; + + fileSystems."/nix" = { + device = "/dev/disk/by-uuid/19a52b40-3ff6-47ff-9402-18d8b289643e"; + fsType = "btrfs"; + options = ["subvol=@nix" "compress=zstd"]; + }; + + fileSystems."/home" = { + device = "/dev/disk/by-uuid/19a52b40-3ff6-47ff-9402-18d8b289643e"; + fsType = "btrfs"; + options = ["subvol=@home" "compress=zstd"]; + }; + + fileSystems."/boot/efi" = { + device = "/dev/disk/by-uuid/EC76-201F"; + fsType = "vfat"; + }; + + swapDevices = []; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + powerManagement.cpuFreqGovernor = lib.mkDefault "powersave"; + hardware.cpu.intel.updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; +} diff --git a/machines/WinMax2/configuration.nix b/machines/WinMax2/configuration.nix new file mode 100755 index 0000000..81f0f44 --- /dev/null +++ b/machines/WinMax2/configuration.nix @@ -0,0 +1,141 @@ +# Edit this configuration file to define what should be installed on +# your system. Help is available in the configuration.nix(5) man page +# and in the NixOS manual (accessible by running `nixos-help`). +{ + config, + pkgs, + lib, + ... +}: { + boot = { + loader = { + systemd-boot.enable = false; + limine = { + enable = true; + enableEditor = true; + secureBoot.enable = true; + extraConfig = '' + timeout: 3 + ''; + style = { + graphicalTerminal.font.scale = "2x2"; + }; + }; + efi = { + efiSysMountPoint = config.fileSystems."efi_boot_partition".mountPoint; + canTouchEfiVariables = true; + }; + }; + /* + I use luks, and the systemd initrd works better for this + Both for tpm unlocking (soon) and for plymouth + */ + initrd.systemd.enable = true; + # Plymouth doesn't support fractional scaling :( + plymouth.extraConfig = "DeviceScale=2"; + + kernelPackages = pkgs.linuxPackages_latest; + }; + catppuccin.limine.enable = true; + + networking.hostName = "WinMax2"; # Define your hostname. + + # Sleep fixes + boot.kernelParams = ["rtc_cmos.use_acpi_alarm=1" "panic=5"]; + services.udev.extraRules = '' + ACTION=="add", SUBSYSTEM=="i2c", ATTR{name}=="GXTP7385:00", ATTR{power/wakeup}="disabled" + ACTION=="add", SUBSYSTEM=="i2c", ATTR{name}=="PNP0C50:00", ATTR{power/wakeup}="disabled" + SUBSYSTEM=="usb", ATTR{idVendor}=="2541", ATTR{idProduct}=="9711", ATTR{remove}="1" + ''; + + services = { + hardware.bolt.enable = true; + handheld-daemon = { + enable = true; + ui = { + enable = true; + }; + user = "toast"; + }; + # Input plumber conflicts with hhd, and it doesn't let me use mouse mode + inputplumber.enable = lib.mkForce false; + }; + + # Allow unfree packages + nixpkgs.config.allowUnfree = true; + + # Configure network proxy if necessary + # networking.proxy.default = "http://user:password@proxy:port/"; + # networking.proxy.noProxy = "127.0.0.1,localhost,internal.domain"; + + services = { + xserver.xkb.layout = lib.mkForce "us"; + }; + + # Enable bluetooth + hardware.bluetooth = { + enable = true; + }; + + # Configure keymap in X11 + # services.xserver.layout = "us"; + # services.xserver.xkbOptions = "eurosign:e,caps:escape"; + + # Enable CUPS to print documents. + # services.printing.enable = true; + home-manager.sharedModules = [ + { + programs.plasma.input.keyboard.layouts = lib.mkForce [{layout = "us";} {layout = "es";}]; + } + ]; + + # Enable sound. + # sound.enable = true; + # hardware.pulseaudio.enable = true; + + # Enable touchpad support (enabled default in most desktopManager). + # services.xserver.libinput.enable = true; + + # Large builds (the linux kernel) fail to build because /tmp is too small when using tmpfs + boot.tmp.useTmpfs = false; + + # List packages installed in system profile. To search, run: + # $ nix search wget + # environment.systemPackages = with pkgs; [ + # vim # Do not forget to add an editor to edit configuration.nix! The Nano editor is also installed by default. + # wget + # ]; + + # Some programs need SUID wrappers, can be configured further or are + # started in user sessions. + # programs.mtr.enable = true; + # programs.gnupg.agent = { + # enable = true; + # enableSSHSupport = true; + # }; + + # List services that you want to enable: + + # Enable the OpenSSH daemon. + # services.openssh.enable = true; + + # Open ports in the firewall. + # networking.firewall.allowedTCPPorts = [ ... ]; + # networking.firewall.allowedUDPPorts = [ ... ]; + # Or disable the firewall altogether. + # networking.firewall.enable = false; + + # Copy the NixOS configuration file and link it from the resulting system + # (/run/current-system/configuration.nix). This is useful in case you + # accidentally delete configuration.nix. + # system.copySystemConfiguration = true; + specialisation.bootDebug.configuration = { + boot.kernelParams = [ + "systemd.debug-shell=1" + "systemd.log_level=debug" + "systemd.log_target=kmsg" + "log_buf_len=1M" + "printk.devkmsg=on" + ]; + }; +} diff --git a/machines/WinMax2/default.nix b/machines/WinMax2/default.nix new file mode 100755 index 0000000..899f761 --- /dev/null +++ b/machines/WinMax2/default.nix @@ -0,0 +1,7 @@ +{...}: { + imports = [ + ./configuration.nix + ./hardware-configuration.nix + ./remote-builder.nix + ]; +} diff --git a/machines/WinMax2/hardware-configuration.nix b/machines/WinMax2/hardware-configuration.nix new file mode 100755 index 0000000..ac8d1cd --- /dev/null +++ b/machines/WinMax2/hardware-configuration.nix @@ -0,0 +1,87 @@ +# Do not modify this file! It was generated by ‘nixos-generate-config’ +# and may be overwritten by future invocations. Please make changes +# to /etc/nixos/configuration.nix instead. +{ + config, + lib, + modulesPath, + ... +}: let + # \x20 is the escape code for a space + ssdLabel = ''Win\\x20Max\\x202\\x20SSD''; +in { + imports = [ + (modulesPath + "/installer/scan/not-detected.nix") + ]; + + boot.initrd.availableKernelModules = ["nvme" "xhci_pci" "thunderbolt" "usbhid" "sdhci_pci"]; + boot.initrd.kernelModules = []; + boot.kernelModules = ["kvm-amd"]; + boot.extraModulePackages = []; + + boot.initrd.luks.devices = { + "SSD".device = "/dev/disk/by-label/wm2-enc"; + "swap".device = "/dev/disk/by-label/wm2-swap"; + }; + + fileSystems = { + "efi_boot_partition" = { + mountPoint = "/boot"; + label = "winmax2boot"; + fsType = "vfat"; + }; + /* + Mount the root subvolume of the SSD + This is helpful for getting things from + my old Arch install, as well as for running btdu + */ + "btrfs_root_subvolume" = { + mountPoint = "/mnt/ssd"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvolid=5" "ro"]; + }; + "btrfs_root" = { + mountPoint = "/"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@"]; + }; + "btrfs_persist" = { + mountPoint = "/persist"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@persist"]; + neededForBoot = true; + }; + "btrfs_home" = { + mountPoint = "/home"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@home"]; + }; + "btrfs_nix" = { + mountPoint = "/nix"; + label = ssdLabel; + fsType = "btrfs"; + options = ["subvol=@nix"]; + }; + }; + + swapDevices = [ + { + device = "/dev/mapper/swap"; + # only want to use the swap partition for hibernating + priority = 0; + } + ]; + + nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux"; + hardware = { + cpu.amd = { + updateMicrocode = lib.mkDefault config.hardware.enableRedistributableFirmware; + ryzen-smu.enable = true; + }; + sensor.iio.bmi260.enable = true; + }; +} diff --git a/machines/WinMax2/remote-builder.nix b/machines/WinMax2/remote-builder.nix new file mode 100644 index 0000000..1a3f839 --- /dev/null +++ b/machines/WinMax2/remote-builder.nix @@ -0,0 +1,56 @@ +{ + config, + flakeSelf, + ... +}: let + hostSecrets = "${flakeSelf.inputs.secrets}/" + config.networking.hostName + "/"; + hostKeyPath = "/etc/ssh/winmax2_host_key"; +in { + age.secrets = { + winmax2-host-key = { + file = hostSecrets + "host-private-key.age"; + path = hostKeyPath; + mode = "0400"; + }; + "winmax2-host-key.pub" = { + file = hostSecrets + "host-public-key.age"; + path = hostKeyPath + ".pub"; + }; + }; + + users = { + groups.nixrbld = {}; + users.nixrbld = { + isSystemUser = true; + useDefaultShell = true; + group = "nixrbld"; + openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF8v+04ZwqHZRG8P8nxdQt+fGJfzlxHXF0F6jzENb+U6 Remote builder access key" + ]; + }; + }; + + nix.settings.trusted-users = ["nixrbld"]; + + services.openssh = { + enable = true; + startWhenNeeded = true; + # I only want it to be accesible though tailscale + openFirewall = false; + allowSFTP = false; + settings = { + UseDns = true; + PermitRootLogin = "no"; + PasswordAuthentication = false; + AllowUsers = ["nixrbld"]; + }; + hostKeys = [ + { + path = hostKeyPath; + type = "ed25519"; + comment = "Everest host key"; + } + ]; + }; + networking.firewall.interfaces.tailscale0.allowedTCPPorts = [22]; +} diff --git a/nixpkgs-patches/.gitkeep b/nixpkgs-patches/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/nixpkgs-patches/pr471291.patch b/nixpkgs-patches/pr471291.patch new file mode 100644 index 0000000..7111330 --- /dev/null +++ b/nixpkgs-patches/pr471291.patch @@ -0,0 +1,61 @@ +From ef52b16862caa43dd4abc0aedf1814796342b664 Mon Sep 17 00:00:00 2001 +From: K900 +Date: Tue, 16 Dec 2025 11:48:37 +0300 +Subject: [PATCH] kdePackages.plasma-vault: refresh patch + +No idea how this happened. +--- + .../plasma/plasma-vault/hardcode-paths.patch | 26 +++++++++---------- + 1 file changed, 13 insertions(+), 13 deletions(-) + +diff --git a/pkgs/kde/plasma/plasma-vault/hardcode-paths.patch b/pkgs/kde/plasma/plasma-vault/hardcode-paths.patch +index d8a5f4a025de3..090df77eb15b0 100644 +--- a/pkgs/kde/plasma/plasma-vault/hardcode-paths.patch ++++ b/pkgs/kde/plasma/plasma-vault/hardcode-paths.patch +@@ -1,5 +1,5 @@ + diff --git a/kded/engine/backends/cryfs/cryfsbackend.cpp b/kded/engine/backends/cryfs/cryfsbackend.cpp +-index f425eb3..5b8cd43 100644 ++index 64138b6..5d249aa 100644 + --- a/kded/engine/backends/cryfs/cryfsbackend.cpp + +++ b/kded/engine/backends/cryfs/cryfsbackend.cpp + @@ -207,7 +207,7 @@ QProcess *CryFsBackend::cryfs(const QStringList &arguments) const +@@ -44,7 +44,7 @@ index b992f6f..eb828dd 100644 + + QString GocryptfsBackend::getConfigFilePath(const Device &device) const + diff --git a/kded/engine/fusebackend_p.cpp b/kded/engine/fusebackend_p.cpp +-index 8763304..e6860d2 100644 ++index 714b660..61d8bf5 100644 + --- a/kded/engine/fusebackend_p.cpp + +++ b/kded/engine/fusebackend_p.cpp + @@ -90,7 +90,7 @@ QProcess *FuseBackend::process(const QString &executable, const QStringList &arg +@@ -57,19 +57,19 @@ index 8763304..e6860d2 100644 + + FutureResult<> FuseBackend::initialize(const QString &name, const Device &device, const MountPoint &mountPoint, const Vault::Payload &payload) + diff --git a/kded/engine/vault.cpp b/kded/engine/vault.cpp +-index c101079..67c8a83 100644 ++index a7a4741..773b671 100644 + --- a/kded/engine/vault.cpp + +++ b/kded/engine/vault.cpp +-@@ -485,7 +485,7 @@ FutureResult<> Vault::close() +- } else { +- // We want to check whether there is an application +- // that is accessing the vault +-- AsynQt::Process::getOutput(QStringLiteral("lsof"), {QStringLiteral("-t"), mountPoint().data()}) | cast() | onError([this] { +-+ AsynQt::Process::getOutput(QStringLiteral("@lsof@"), {QStringLiteral("-t"), mountPoint().data()}) | cast() | onError([this] { +- d->updateMessage(i18n("Unable to lock the vault because an application is using it")); +- }) | onSuccess([this](const QString &result) { +- // based on ksolidnotify.cpp +-@@ -538,7 +538,7 @@ FutureResult<> Vault::forceClose() ++@@ -490,7 +490,7 @@ FutureResult<> Vault::close() ++ } else { ++ // We want to check whether there is an application ++ // that is accessing the vault ++- AsynQt::Process::getOutput(QStringLiteral("lsof"), { QStringLiteral("-t"), mountPoint().data() }) +++ AsynQt::Process::getOutput(QStringLiteral("@lsof@"), { QStringLiteral("-t"), mountPoint().data() }) ++ | cast() ++ | onError([this] { ++ d->updateMessage(i18n("Unable to close the vault because an application is using it")); ++@@ -546,7 +546,7 @@ FutureResult<> Vault::forceClose() + using namespace AsynQt::operators; + + AsynQt::await( diff --git a/nixpkgs-patches/revert-mangohud-update.patch b/nixpkgs-patches/revert-mangohud-update.patch new file mode 100644 index 0000000..067618e --- /dev/null +++ b/nixpkgs-patches/revert-mangohud-update.patch @@ -0,0 +1,49 @@ +diff --git a/pkgs/tools/graphics/mangohud/default.nix b/pkgs/tools/graphics/mangohud/default.nix +index cf83d4254baa..992afd60d3cb 100644 +--- a/pkgs/tools/graphics/mangohud/default.nix ++++ b/pkgs/tools/graphics/mangohud/default.nix +@@ -24,8 +24,10 @@ + unzip, + wayland, + libXNVCtrl, ++ nlohmann_json, + spdlog, + libxkbcommon, ++ glew, + glfw, + libXrandr, + x11Support ? true, +@@ -93,14 +95,14 @@ let + in + stdenv.mkDerivation (finalAttrs: { + pname = "mangohud"; +- version = "0.8.2"; ++ version = "0.8.1"; + + src = fetchFromGitHub { + owner = "flightlessmango"; + repo = "MangoHud"; + tag = "v${finalAttrs.version}"; + fetchSubmodules = true; +- hash = "sha256-BZ3R7D2zOlg69rx4y2FzzjpXuPOv913TOz9kSvRN+Wg="; ++ hash = "sha256-FvPhnOvcYE8vVB5R+ZRmuZxrC9U4GA338V7VAuUlHCE="; + }; + + outputs = [ +@@ -188,6 +190,7 @@ stdenv.mkDerivation (finalAttrs: { + + buildInputs = [ + dbus ++ nlohmann_json + spdlog + ] + ++ lib.optional waylandSupport wayland +@@ -195,6 +198,7 @@ stdenv.mkDerivation (finalAttrs: { + ++ lib.optional nvidiaSupport libXNVCtrl + ++ lib.optional (x11Support || waylandSupport) libxkbcommon + ++ lib.optionals mangoappSupport [ ++ glew + glfw + libXrandr + ]; + diff --git a/openbox.nix b/openbox.nix deleted file mode 100755 index 505b90f..0000000 --- a/openbox.nix +++ /dev/null @@ -1,24 +0,0 @@ -{ config, pkgs, ... }: - -{ - services = { - xserver = { - enable = true; - autorun = false; - windowManager.openbox.enable = true; - }; - xrdp = { - enable = true; - openFirewall = true; - defaultWindowManager = "${pkgs.openbox}/bin/openbox-session"; - #confDir = "/etc/xrdp"; - }; - }; - environment.systemPackages = with pkgs; [ - pcmanfm - obconf - firefox - gnome.gnome-calculator - alacritty - ]; -} diff --git a/pkgs/kame-editor/default.nix b/pkgs/kame-editor/default.nix new file mode 100644 index 0000000..b3cd0a7 --- /dev/null +++ b/pkgs/kame-editor/default.nix @@ -0,0 +1,59 @@ +{ + lib, + stdenv, + fetchFromGitLab, + qt6, + portaudio, + kame-tools, + vgmstream, + rstmcpp, +}: +stdenv.mkDerivation rec { + name = "kame-editor"; + version = "1.4.1"; + + src = fetchFromGitLab { + owner = "beelzy"; + repo = name; + # tag = version; + rev = "82c9c445644b133b6d0ce3529e65b1a3df83c804"; + hash = "sha256-V2nMvVIjFRM8++XQ9tkE2OiZzCvdrg0jK69HM+ZIVyA="; + }; + + postPatch = '' + substituteInPlace kame-editor.pro \ + --replace-fail "/usr/local/bin/" "$out/bin" + ''; + + buildInputs = [ + qt6.qtbase + portaudio + ]; + + qtWrapperArgs = [ + "--prefix PATH : ${ + lib.makeBinPath [ + kame-tools + vgmstream + rstmcpp + ] + }" + ]; + + nativeBuildInputs = [ + qt6.qmake + qt6.wrapQtAppsHook + ]; + + postBuild = '' + bash ./buildicons.sh + ''; + + postInstall = '' + mkdir -p $out/share/icons/hicolor + mkdir -p $out/share/applications + + cp kame-editor.desktop $out/share/applications + cp -r icons/. $out/share/icons/hicolor + ''; +} diff --git a/pkgs/kame-tools/default.nix b/pkgs/kame-tools/default.nix new file mode 100644 index 0000000..fe2b608 --- /dev/null +++ b/pkgs/kame-tools/default.nix @@ -0,0 +1,29 @@ +{ + stdenv, + fetchFromGitLab, + zip, +}: +stdenv.mkDerivation rec { + name = "kame-tools"; + version = "a1fe47cc"; + + src = fetchFromGitLab { + owner = "beelzy"; + repo = name; + rev = version; + fetchSubmodules = true; + hash = "sha256-ETl5f8M4OJPFB7NEq2mVuMm4RhBtAbMzlrvGHD14zXw="; + }; + + postPatch = '' + substituteInPlace buildtools/make_base \ + --replace-fail "/usr/local/bin" "$out/bin" + ''; + + installPhase = '' + mkdir -p $out/bin + cp output/linux-x86_64/* $out/bin/ + ''; + + nativeBuildInputs = [zip]; +} diff --git a/pkgs/kasane-teto-cursor b/pkgs/kasane-teto-cursor new file mode 100644 index 0000000..af93194 --- /dev/null +++ b/pkgs/kasane-teto-cursor @@ -0,0 +1,18 @@ +{ + stdenvNoCC, + fetchzip, +}: +stdenvNoCC.mkDerivation { + name = "kasane-teto-cursors"; + + src = fetchzip { + url = "http://dl.everest.tailscale/Kasane%20Teto%20Cursor%20-%20by%20wobb.zip"; + hash = "sha256-4neZqApkK6hwufLTilUtPmgzyBih7onSdSZ9lezQbIU="; + }; + + dontBuild = true; + installPhase = '' + mkdir -p $out/share/icons + cp -dr --no-preserve='ownership' $src/Linux/Kasane\ Teto $out/share/icons + ''; +} diff --git a/pkgs/rstmcpp/default.nix b/pkgs/rstmcpp/default.nix new file mode 100644 index 0000000..94c51a3 --- /dev/null +++ b/pkgs/rstmcpp/default.nix @@ -0,0 +1,21 @@ +{ + stdenv, + fetchFromGitLab, +}: +stdenv.mkDerivation rec { + name = "rstmcpp"; + version = "fe8bee01"; + + src = fetchFromGitLab { + owner = "beelzy"; + repo = name; + rev = version; + fetchSubmodules = true; + hash = "sha256-T9mxTBj/eykvbBkbmEKTUFldtBp3cJgWAbeu44SwxiM="; + }; + + installPhase = '' + mkdir -p $out/bin + cp rstmcpp $out/bin + ''; +} diff --git a/roles/common/configuration.nix b/roles/common/configuration.nix index 96b3b9e..998bbef 100755 --- a/roles/common/configuration.nix +++ b/roles/common/configuration.nix @@ -1,51 +1,192 @@ -{ config, pkgs, ... }: - { - environment = { - # As of the 1st of May 2023, the default packages are nano, perl, rsync and strace - # I don't need any of them, so I just empty the list - defaultPackages = []; - variables = { - # Environment variables go here - EDITOR = "micro"; - }; - }; + config, + lib, + pkgs, + flakeSelf, + ... +}: { + environment = { + # As of the 1st of May 2023, the default packages are nano, perl, rsync and strace + # I don't need any of them, so I just empty the list + defaultPackages = []; + }; - # Set up secrets - age = { - identityPaths = [ - "/etc/ssh/ssh_host_rsa_key" - "/etc/ssh/ssh_host_ed25519_key" - # This key has a passcode, so if you need to use it you'll have to - # enter the password A LOT of times. Only on the first setup tho - "/tmp/id_ed25519_bootstrap" - ]; - # Copy (NOT SYMLINK) host ssh keys into place - secrets = { - "ed25519" = { - symlink = false; - file = ../../secrets/${config.networking.hostName}/host-key-ed25519; - path = "/etc/ssh/ssh_host_ed25519_key"; - }; - "rsa" = { - symlink = false; - file = ../../secrets/${config.networking.hostName}/host-key-rsa; - path= "/etc/ssh/ssh_host_rsa_key"; - }; - "ed25519-public" = { - symlink = false; - file = ../../secrets/${config.networking.hostName}/host-key-ed25519-public; - path = "/etc/ssh/ssh_host_ed25519_key.pub"; - mode = "0644"; - }; - "rsa-public" = { - symlink = false; - file = ../../secrets/${config.networking.hostName}/host-key-rsa-public; - path = "/etc/ssh/ssh_host_rsa_key.pub"; - mode = "0644"; - }; - }; - }; + # Set up /tmp + boot.tmp = { + useTmpfs = false; + # Cleaning out /tmp at boot if it's a tmpfs is quite stupid + cleanOnBoot = !config.boot.tmp.useTmpfs; + }; - system.stateVersion = "23.05"; + environment.localBinInPath = lib.mkDefault true; + + # Set up zram + zramSwap = { + enable = true; + priority = 100; + memoryPercent = 60; + # zstd my beloved <3 + algorithm = "zstd"; + }; + # zswap with zram is not a good idea + boot.kernelParams = ["zswap.enabled=0"]; + + # Set up keyboard layout + services.xserver.xkb.layout = "es"; + + # Set up console + console = { + packages = [pkgs.terminus_font]; + earlySetup = true; + # mkDefault has 1000 priority, so that way I don't conflict with nixos-hardware + font = lib.mkOverride 999 "ter-i16n"; + # Make the console use X's keyboard configuration + useXkbConfig = true; + }; + + boot.supportedFilesystems = ["nfs"]; + + security.pki.certificates = [ + # Caddy + '' + -----BEGIN CERTIFICATE----- + MIIBqTCCAU+gAwIBAgIQceh0ZUBNrOmqLVsDr+2HBjAKBggqhkjOPQQDAjAzMTEw + LwYDVQQDEyhDYWRkeSAoRXZlcmVzdCkgbG9jYWwgQ0EgLSAyMDI0IEVDQyBSb290 + MB4XDTI0MDcxODAwMDEwM1oXDTM0MDUyNzAwMDEwM1owMzExMC8GA1UEAxMoQ2Fk + ZHkgKEV2ZXJlc3QpIGxvY2FsIENBIC0gMjAyNCBFQ0MgUm9vdDBZMBMGByqGSM49 + AgEGCCqGSM49AwEHA0IABJjrY8x6iDXncxG8exwLyaEq8N0XnCIbga8PVYiz3VLS + 07++i0Dey9k68ag6KUZICfc8dX1uZ6/ozUZb4YO3xCSjRTBDMA4GA1UdDwEB/wQE + AwIBBjASBgNVHRMBAf8ECDAGAQH/AgEBMB0GA1UdDgQWBBTQAqZS9KeiAr9mSPV9 + RIJbSvRsTzAKBggqhkjOPQQDAgNIADBFAiBIgHrD2cUfNEctVx0WzHb0cLAsrK4Q + 3QbyxPLyenR+dAIhAMOIAyxlKKKvAJMOzAg0r+csSVRdP1YHsHiE7U1GeHWx + -----END CERTIFICATE----- + '' + ]; + + # Set up localisation + i18n = { + defaultLocale = "en_US.UTF-8"; + supportedLocales = [ + "es_US.UTF-8/UTF-8" + "es_ES.UTF-8/UTF-8" + "en_US.UTF-8/UTF-8" + ]; + extraLocaleSettings = { + LC_NUMERIC = "es_ES.UTF-8"; + # am/pm is nice but mm/dd/yy is yucky + LC_TIME = "es_US.UTF-8"; + LC_MONETARY = "es_ES.UTF-8"; + LC_MEASUREMENT = "es_ES.UTF-8"; + LC_PAPER = "es_ES.UTF-8"; + LC_ADDRESS = "es_US.UTF-8"; + LC_NAME = "es_ES.UTF-8"; + LC_TELEPHONE = "es_ES.UTF-8"; + }; + }; + + services = { + fstrim.enable = true; + fwupd.enable = true; + }; + + # Set up my user + users.mutableUsers = false; + users.users.toast = { + isNormalUser = true; + description = "Toast"; + extraGroups = ["wheel"]; + hashedPasswordFile = config.sops.secrets.toast.path; + }; + + # Set up time zone. + time.timeZone = lib.mkDefault "Europe/Madrid"; + services.automatic-timezoned.enable = true; + + nixpkgs.overlays = [ + flakeSelf.outputs.overlays.default + ( + final: prev: { + catppuccin = prev.catppuccin.override { + accent = "mauve"; + variant = "mocha"; + themeList = [ + "bat" + "btop" + "starship" + "grub" + ]; + }; + } + ) + ]; + + catppuccin = { + flavor = "mocha"; + accent = "mauve"; + }; + + programs.iotop.enable = true; + + home-manager = { + backupFileExtension = "hm-backup"; + useGlobalPkgs = true; + verbose = true; + sharedModules = with flakeSelf; [ + inputs.catppuccin.homeModules.catppuccin + inputs.sops-nix.homeManagerModules.sops + ]; + users.toast = {osConfig, ...}: { + catppuccin.flavor = osConfig.catppuccin.flavor; + catppuccin.accent = osConfig.catppuccin.accent; + home.stateVersion = "25.05"; + manual = { + manpages.enable = true; + html.enable = true; + }; + xdg = { + enable = true; + userDirs = { + enable = true; + createDirectories = true; + publicShare = null; # Disable the public folder + }; + }; + systemd.user.startServices = true; + }; + }; + + # Set up secrets + age = { + identityPaths = [ + "/persist/id_host" + ]; + }; + sops = { + age.sshKeyPaths = ["/persist/id_host"]; + defaultSopsFile = "${flakeSelf.inputs.secrets}/${config.networking.hostName}.yaml"; + secrets.toast = { + sopsFile = "${flakeSelf.inputs.secrets}/passwd.yaml"; + neededForUsers = true; + }; + }; + + catppuccin.grub.enable = true; + + /* + I used to keep the host keys in the repo as a secret, but since I use the + host keys for decrypting too I'm not sure encrypting a key with itself + is a good idea. Now the host keys will need to be placed manually where they are needed + For first time installs they are generated by services.openssh.hostKeys on servers, and + manually on everything else + */ + + system = { + stateVersion = "25.05"; + # Nix on nixos 23.05 does not have dirtyRev + configurationRevision = flakeSelf.sourceInfo.rev or flakeSelf.sourceInfo.dirtyRev or "dirty"; + nixos.variant_id = lib.mkDefault (lib.strings.toLower config.networking.hostName); + }; + image.modules.iso = { + system.nixos.variant_id = "${lib.strings.toLower config.networking.hostName}-iso"; + }; } diff --git a/roles/common/default.nix b/roles/common/default.nix index 91e9eb3..c9bfd8b 100755 --- a/roles/common/default.nix +++ b/roles/common/default.nix @@ -1,9 +1,7 @@ -{ ... }: - -{ - imports = [ - ./programs - ./services/avahi.nix - ./configuration.nix - ]; +{...}: { + imports = [ + ./programs + ./services + ./configuration.nix + ]; } diff --git a/roles/common/programs/atuin.nix b/roles/common/programs/atuin.nix new file mode 100644 index 0000000..ab493cd --- /dev/null +++ b/roles/common/programs/atuin.nix @@ -0,0 +1,21 @@ +{...}: { + home-manager.users.toast = { + catppuccin.atuin.enable = true; + programs.atuin = { + enable = true; + settings = { + enter_accept = true; + workspaces = true; + filter_mode = "workspace"; + style = "auto"; + inline_height = 0; + stats = { + common_prefix = [ + "sudo" + "," + ]; + }; + }; + }; + }; +} diff --git a/roles/common/programs/bash.nix b/roles/common/programs/bash.nix new file mode 100644 index 0000000..66bfbea --- /dev/null +++ b/roles/common/programs/bash.nix @@ -0,0 +1,8 @@ +{...}: { + home-manager.users.toast = {...}: { + programs.bash = { + enable = true; + enableVteIntegration = true; + }; + }; +} diff --git a/roles/common/programs/bat.nix b/roles/common/programs/bat.nix new file mode 100644 index 0000000..068cc1d --- /dev/null +++ b/roles/common/programs/bat.nix @@ -0,0 +1,8 @@ +{...}: { + home-manager = { + users.toast = { + programs.bat.enable = true; + catppuccin.bat.enable = true; + }; + }; +} diff --git a/roles/common/programs/btop.nix b/roles/common/programs/btop.nix new file mode 100644 index 0000000..75fd33b --- /dev/null +++ b/roles/common/programs/btop.nix @@ -0,0 +1,8 @@ +{...}: { + home-manager = { + users.toast = { + catppuccin.btop.enable = true; + programs.btop.enable = true; + }; + }; +} diff --git a/roles/common/programs/comma.nix b/roles/common/programs/comma.nix new file mode 100644 index 0000000..c04afcb --- /dev/null +++ b/roles/common/programs/comma.nix @@ -0,0 +1,6 @@ +{...}: { + # Use nix-index-database's comma wrapper + programs.nix-index-database.comma.enable = true; + # Run programs from the system's nixpkgs + environment.variables = {COMMA_NIXPKGS_FLAKE = "system";}; +} diff --git a/roles/common/programs/command-not-found.nix b/roles/common/programs/command-not-found.nix index 0816b39..e3dcd3a 100755 --- a/roles/common/programs/command-not-found.nix +++ b/roles/common/programs/command-not-found.nix @@ -1,6 +1,4 @@ -{ config, ... }: - -{ - # The nixpkgs command-not-found script does not work with flakes, so I disable it - programs.command-not-found.enable = false; +{...}: { + # The nixpkgs command-not-found script does not work with flakes, so I disable it + programs.command-not-found.enable = false; } diff --git a/roles/common/programs/default.nix b/roles/common/programs/default.nix index d28c74d..48b27ba 100755 --- a/roles/common/programs/default.nix +++ b/roles/common/programs/default.nix @@ -1,23 +1,32 @@ -{ config, pkgs, ... }: - -{ - imports = [ - ./htop.nix - ./nix.nix - ./nix-index.nix - ./command-not-found.nix - ]; - # Some programs dont have a programs.*.enable option, so I install their package here - environment.systemPackages = with pkgs; [ - speedtest-cli - bat - micro - nvd - ncdu - tree - btdu - btop - iperf3 - restic - ]; +{pkgs, ...}: { + imports = [ + ./htop.nix + ./nix.nix + ./nix-index.nix + ./command-not-found.nix + ./comma.nix + ./bash.nix + ./git.nix + ./starship.nix + ./bat.nix + ./btop.nix + ./helix.nix + ./direnv.nix + ./atuin.nix + ./fish.nix + ./eza.nix + ]; + # Some programs dont have a programs.*.enable option, so I install their package here + environment.systemPackages = with pkgs; [ + speedtest-cli + # Bat has a home manager module, but I want it to be available system wide + bat + file + nvd + ncdu + tree + btdu + iperf3 + restic + ]; } diff --git a/roles/common/programs/direnv.nix b/roles/common/programs/direnv.nix new file mode 100644 index 0000000..e4a4e29 --- /dev/null +++ b/roles/common/programs/direnv.nix @@ -0,0 +1,10 @@ +{...}: { + home-manager.users.toast = { + programs.direnv = { + enable = true; + nix-direnv = { + enable = true; + }; + }; + }; +} diff --git a/roles/common/programs/eza.nix b/roles/common/programs/eza.nix new file mode 100644 index 0000000..90602d6 --- /dev/null +++ b/roles/common/programs/eza.nix @@ -0,0 +1,20 @@ +{ + flakeSelf, + config, + ... +}: { + home-manager = { + users.toast = { + programs.eza = { + enable = true; + enableBashIntegration = true; + git = true; + icons = "auto"; + extraOptions = [ + "--group" + ]; + }; + xdg.configFile."eza/theme.yml".source = "${flakeSelf.inputs.eza-themes}/themes/catppuccin.yml"; + }; + }; +} diff --git a/roles/common/programs/fish.nix b/roles/common/programs/fish.nix new file mode 100644 index 0000000..3bafd1c --- /dev/null +++ b/roles/common/programs/fish.nix @@ -0,0 +1,10 @@ +{...}: { + programs.fish = { + enable = true; + }; + + home-manager.users.toast = { + catppuccin.fish.enable = true; + programs.fish.enable = true; + }; +} diff --git a/roles/common/programs/git.nix b/roles/common/programs/git.nix new file mode 100644 index 0000000..2709d45 --- /dev/null +++ b/roles/common/programs/git.nix @@ -0,0 +1,25 @@ +{...}: { + programs.ssh.knownHosts = { + "[git.toast003.xyz]:4222".publicKey = '' + ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKoUcWx56NZ3kqydN3d0gLNz6SlBm1ArkHhqR9Fwd8qs + ''; + }; + home-manager.users.toast = { + programs.git = { + enable = true; + settings = { + user.name = "toast"; + user.email = "toast003@tutamail.com"; + init.defaultBranch = "main"; + diff.colorMoved = "default"; + commit.verbose = "true"; + }; + }; + + programs.delta = { + enable = true; + enableGitIntegration = true; + }; + catppuccin.delta.enable = true; + }; +} diff --git a/roles/common/programs/helix.nix b/roles/common/programs/helix.nix new file mode 100644 index 0000000..7a52995 --- /dev/null +++ b/roles/common/programs/helix.nix @@ -0,0 +1,54 @@ +{ + pkgs, + config, + ... +}: { + programs.nano.enable = false; + home-manager.users.toast = { + catppuccin.helix = { + enable = true; + useItalics = true; + }; + programs.helix = { + enable = true; + defaultEditor = true; + extraPackages = with pkgs; [ + nixpkgs-fmt + nil + taplo + ]; + settings = { + editor = { + mouse = true; + cursorline = true; + color-modes = true; + bufferline = "multiple"; + statusline.mode = { + normal = "Normal"; + insert = "Insert"; + select = "Select"; + }; + indent-guides.render = true; + end-of-line-diagnostics = "hint"; + inline-diagnostics.cursor-line = "warning"; + }; + }; + languages = { + language = [ + { + name = "nix"; + formatter.command = "nixpkgs-fmt"; + } + ]; + language-server.nil = { + config = { + flake = { + autoArchive = false; + autoEvalInputs = true; + }; + }; + }; + }; + }; + }; +} diff --git a/roles/common/programs/htop.nix b/roles/common/programs/htop.nix index 4eb7fbe..4d8bd79 100755 --- a/roles/common/programs/htop.nix +++ b/roles/common/programs/htop.nix @@ -1,15 +1,13 @@ -{ config, ... }: - -{ - programs.htop = { - enable = true; - settings = { - tree_view = 1; - highlight_base_name = 1; - show_program_path = 0; - show_cpu_frequency = 1; - show_cpu_temperature = 1; - hide_userland_threads = 1; - }; - }; +{...}: { + programs.htop = { + enable = true; + settings = { + tree_view = 1; + highlight_base_name = 1; + show_program_path = 0; + show_cpu_frequency = 1; + show_cpu_temperature = 1; + hide_userland_threads = 1; + }; + }; } diff --git a/roles/common/programs/nix-index.nix b/roles/common/programs/nix-index.nix index 93e88d4..c3a8177 100755 --- a/roles/common/programs/nix-index.nix +++ b/roles/common/programs/nix-index.nix @@ -1,8 +1,15 @@ -{ config, pkgs, ... }: - -{ - environment.systemPackages = [ pkgs.nix-index ]; - programs.bash.interactiveShellInit = '' -source ${pkgs.nix-index}/etc/profile.d/command-not-found.sh - ''; +{...}: { + /* + environment.systemPackages = [ pkgs.nix-index ]; + programs.bash.interactiveShellInit = '' + source ${pkgs.nix-index}/etc/profile.d/command-not-found.sh + ''; + */ + programs.nix-index = { + enable = true; + enableBashIntegration = true; + # I don't use zsh or fish (yet) + enableZshIntegration = false; + enableFishIntegration = false; + }; } diff --git a/roles/common/programs/nix.nix b/roles/common/programs/nix.nix index ea40ccd..a676fbd 100755 --- a/roles/common/programs/nix.nix +++ b/roles/common/programs/nix.nix @@ -1,15 +1,62 @@ -{ config, ... }: - { - nix = { - extraOptions = '' -experimental-features = nix-command flakes - ''; - registry = { - agenix = { - from = { id = "agenix"; type = "indirect"; }; - to = { owner = "ryantm"; repo = "agenix"; type = "github"; }; - }; - }; - }; + config, + lib, + flakeSelf, + ... +}: { + age.secrets = { + remoteBuilderKey.file = "${flakeSelf.inputs.secrets}/WinMax2/nixrbld-private-key.age"; + }; + + programs.ssh = { + knownHosts.winmax2.publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPE+ksvEq/I2LMLOztVXpLE9yuI6EkRh4EtXdlYkhl6C WinMax2 host key"; + extraConfig = '' + Host nixrbld + HostName winmax2 + IdentitiesOnly yes + IdentityFile ${config.age.secrets.remoteBuilderKey.path} + User nixrbld + ''; + }; + + system.tools.nixos-option.enable = false; + + nix = { + settings = { + auto-optimise-store = true; + experimental-features = "nix-command flakes"; + }; + distributedBuilds = true; + # Don't use remote builder on the remote builder + buildMachines = lib.mkIf (config.networking.hostName != "WinMax2") [ + { + hostName = "nixrbld"; + system = "x86_64-linux"; + protocol = "ssh-ng"; + maxJobs = 4; + supportedFeatures = [ + "big-parallel" + "kvm" + "nixos-test" + ]; + } + ]; + optimise = { + automatic = true; + dates = ["weekly"]; + }; + registry = { + agenix = { + from = { + id = "agenix"; + type = "indirect"; + }; + to = { + owner = "ryantm"; + repo = "agenix"; + type = "github"; + }; + }; + }; + }; } diff --git a/roles/common/programs/starship.nix b/roles/common/programs/starship.nix new file mode 100644 index 0000000..f270a27 --- /dev/null +++ b/roles/common/programs/starship.nix @@ -0,0 +1,31 @@ +{...}: { + programs.starship = { + enable = true; + presets = [ + "nerd-font-symbols" + ]; + settings = { + nix_shell = { + disabled = false; + heuristic = true; + }; + os = { + disabled = false; + }; + directory = { + disabled = false; + truncation_length = 6; + truncation_symbol = ".../"; + }; + }; + }; + # The catppuccin module only works for home-manager, so this + # sets up starship with home-manager using the system config + # home-manager.users.toast = {osConfig, ...}: { + # programs.starship = { + # enable = false; + # catppuccin.enable = true; + # settings = osConfig.programs.starship.settings; + # }; + # }; +} diff --git a/roles/common/services/avahi.nix b/roles/common/services/avahi.nix index 2891e8d..f7f33e6 100755 --- a/roles/common/services/avahi.nix +++ b/roles/common/services/avahi.nix @@ -1,8 +1,6 @@ -{ config, ... }: - -{ - services.avahi = { - enable = true; - nssmdns = true; - }; +{config, ...}: { + services.avahi = { + enable = true; + nssmdns4 = true; + }; } diff --git a/roles/common/services/default.nix b/roles/common/services/default.nix new file mode 100644 index 0000000..6640381 --- /dev/null +++ b/roles/common/services/default.nix @@ -0,0 +1,8 @@ +{...}: { + imports = [ + ./avahi.nix + ./tailscale.nix + ./syncthing.nix + ./kmscon.nix + ]; +} diff --git a/roles/common/services/kmscon.nix b/roles/common/services/kmscon.nix new file mode 100644 index 0000000..dbd31be --- /dev/null +++ b/roles/common/services/kmscon.nix @@ -0,0 +1,29 @@ +{pkgs, ...}: { + services.kmscon = { + enable = true; + useXkbConfig = true; + fonts = [ + { + name = "JetBrains Mono Nerd Font"; + package = pkgs.nerd-fonts.jetbrains-mono; + } + ]; + extraConfig = '' + term=xterm-256color + font-size=10 + ''; + package = pkgs.kmscon.overrideAttrs (old: { + patches = + old.patches + ++ [ + # https://github.com/kmscon/kmscon/issues/133 + ( + pkgs.fetchpatch { + url = "https://github.com/Aetf/kmscon/pull/135.patch"; + hash = "sha256-hJrKkONdQmz9gGMXbk11+4MF8Vn4guE3Bl1Ni6SGDw4="; + } + ) + ]; + }); + }; +} diff --git a/roles/common/services/syncthing.nix b/roles/common/services/syncthing.nix new file mode 100755 index 0000000..820e64f --- /dev/null +++ b/roles/common/services/syncthing.nix @@ -0,0 +1,40 @@ +{ + config, + lib, + flakeSelf, + ... +}: let + hostSecrets = "${flakeSelf.inputs.secrets}/" + config.networking.hostName; +in { + # Get secrets + age.secrets = { + syncthingKey.file = hostSecrets + "/syncthingKey.age"; + syncthingCert.file = hostSecrets + "/syncthingCert.age"; + }; + + services.syncthing = { + key = config.age.secrets.syncthingKey.path; + cert = config.age.secrets.syncthingCert.path; + overrideDevices = true; + overrideFolders = true; + openDefaultPorts = true; + settings = { + options = { + urAccepted = 3; + }; + # Set up devices and folders common to every device + devices = lib.toast.syncthing.devices; + folders = { + "passwords" = { + label = "KeePassXC Passwords"; + id = "rdyaq-ex659"; + devices = ["phone" "pc" "steamdeck" "server" "surface" "winmax2"]; + }; + }; + }; + }; + networking.firewall = { + allowedTCPPorts = [22000]; + allowedUDPPorts = [22000 21027]; + }; +} diff --git a/roles/common/services/tailscale.nix b/roles/common/services/tailscale.nix new file mode 100644 index 0000000..a7c0338 --- /dev/null +++ b/roles/common/services/tailscale.nix @@ -0,0 +1,10 @@ +{lib, ...}: { + services.tailscale = { + enable = true; + useRoutingFeatures = lib.mkDefault "client"; + }; + + systemd.services.tailscaled.environment = { + TS_NO_LOGS_NO_SUPPORT = "true"; + }; +} diff --git a/roles/desktop/configuration.nix b/roles/desktop/configuration.nix new file mode 100644 index 0000000..048f8e6 --- /dev/null +++ b/roles/desktop/configuration.nix @@ -0,0 +1,35 @@ +{pkgs, ...}: { + # Enable scanning + hardware.sane = { + enable = true; + extraBackends = [pkgs.sane-airscan]; + }; + users.users.toast.extraGroups = ["scanner"]; + + services.xserver.enable = true; + + nix = { + daemonIOSchedClass = "idle"; + daemonCPUSchedPolicy = "idle"; + }; + + # Set up fonts + fonts.packages = with pkgs.nerd-fonts; [ + hack + jetbrains-mono + + # Japanese fonts + pkgs.noto-fonts-cjk-sans + pkgs.noto-fonts-cjk-serif + ]; + + # Already use electron apps (discord) so this only adds 20 mb more + environment.systemPackages = [pkgs.tetrio-desktop]; + + hardware.keyboard.qmk.enable = true; + home-manager.users.toast.home.packages = [pkgs.qmk]; + + home-manager.users.toast.xdg.autostart.enable = true; + + boot.plymouth.enable = true; +} diff --git a/roles/desktop/default.nix b/roles/desktop/default.nix old mode 100755 new mode 100644 index 52f5478..d8e1f2b --- a/roles/desktop/default.nix +++ b/roles/desktop/default.nix @@ -1,7 +1,7 @@ -{ ... }: - -{ - imports = [ - ./discord.nix - ]; +{...}: { + imports = [ + ./services + ./programs + ./configuration.nix + ]; } diff --git a/roles/desktop/discord.nix b/roles/desktop/discord.nix deleted file mode 100644 index bdabdc4..0000000 --- a/roles/desktop/discord.nix +++ /dev/null @@ -1,19 +0,0 @@ -{ config, pkgs, ... }: - -{ - # TODO: Find out why this does not do anything. If I put this - # on flake.nix it does work, so it's not completely wrong. - /* nixpkgs.overlays = - let - discordOverlay = self: super: { - discord = super.discord.override { - withOpenASAR = true; - withVencord = true; - }; - }; - in - [ discordOverlay ]; */ - users.users.toast.packages = with pkgs; [ - discord - ]; -} diff --git a/roles/desktop/programs/appimage.nix b/roles/desktop/programs/appimage.nix new file mode 100644 index 0000000..7e90c2e --- /dev/null +++ b/roles/desktop/programs/appimage.nix @@ -0,0 +1,6 @@ +{...}: { + programs.appimage = { + enable = true; + binfmt = true; + }; +} diff --git a/roles/desktop/programs/default.nix b/roles/desktop/programs/default.nix new file mode 100755 index 0000000..1e2eac3 --- /dev/null +++ b/roles/desktop/programs/default.nix @@ -0,0 +1,15 @@ +{...}: { + imports = [ + ./discord.nix + ./firefox.nix + ./keepassxc.nix + ./jamesdsp.nix + ./git.nix + ./ssh.nix + ./appimage.nix + ./mpv.nix + ./sysdvr-qt.nix + ./spotify.nix + ./distrobox.nix + ]; +} diff --git a/roles/desktop/programs/discord.nix b/roles/desktop/programs/discord.nix new file mode 100644 index 0000000..ca48e5d --- /dev/null +++ b/roles/desktop/programs/discord.nix @@ -0,0 +1,59 @@ +{pkgs, ...}: let + discordOverlay = _self: super: { + discord = super.discord.override { + withOpenASAR = true; + withVencord = true; + }; + }; +in { + nixpkgs.overlays = [discordOverlay]; + home-manager.users.toast = { + catppuccin.vesktop.enable = true; + programs.vesktop = { + enable = true; + settings = { + arRPC = true; + minimizeToTray = true; + discordBranch = "stable"; + customTitleBar = true; + spellCheckLanguages = ["en-US" "en" "es"]; + enableMenu = false; + }; + vencord = { + settings = { + plugins = { + BetterGifPicker.enabled = true; + BetterSettings.enabled = true; + CallTimer.enabled = true; + ClearURLs.enabled = true; + FakeNitro.enabled = true; + FixSpotifyEmbeds.enabled = true; + FixYoutubeEmbeds.enabled = true; + FriendsSince.enabled = true; + iLoveSpam.enabled = true; + LoadingQuotes = { + enabled = true; + enableDiscordPresetQuotes = true; + }; + MessageClickActions.enabled = true; + MessageLinkEmbeds.enabled = true; + MessageLogger.enabled = true; + Moyai = { + enabled = true; + volume = 1; + quality = "HD"; + }; + OpenInApp.enabled = true; + petpet.enabled = true; + PlatformIndicators.enabled = true; + ShowHiddenChannels.enabled = true; + ShowHiddenThings.enabled = true; + SpotifyControls.enabled = true; + Translate.enabled = true; + YoutubeAdblock.enabled = true; + }; + }; + }; + }; + }; +} diff --git a/roles/desktop/programs/distrobox.nix b/roles/desktop/programs/distrobox.nix new file mode 100644 index 0000000..6de5db5 --- /dev/null +++ b/roles/desktop/programs/distrobox.nix @@ -0,0 +1,28 @@ +{...}: { + virtualisation.podman = { + # Due to limitations with home-manager, podman has to be available system wide + enable = true; + }; + home-manager.users.toast = { + programs.distrobox = { + enable = true; + containers = { + uav = { + image = "quay.io/toolbx/ubuntu-toolbox:24.04"; + # additional_packages = "lsb-release dmidecode git"; + additional_packages = "git"; + init_hooks = [ + "git clone https://github.com/PX4/PX4-Autopilot.git --recursive /tmp/px4" + "cd /tmp/px4/Tools/setup/" + # The install scripts wants to add things to .bashrc, so it errors out + # This removes the line that does that + "sed -i '181d' ubuntu.sh" + "bash ubuntu.sh" + "rm -rf /tmp/px4 --one-file-system" + ]; + entry = true; + }; + }; + }; + }; +} diff --git a/roles/desktop/programs/firefox.nix b/roles/desktop/programs/firefox.nix new file mode 100644 index 0000000..538456a --- /dev/null +++ b/roles/desktop/programs/firefox.nix @@ -0,0 +1,147 @@ +{lib, ...}: { + home-manager.sharedModules = [ + { + # System wide firefox settings + programs.firefox = { + enable = true; + policies = { + DisableTelemetry = true; + GenerativeAI = { + Chatbot = false; + LinkPreviews = false; + TabGroups = false; + Locked = true; + }; + # You need these for Spotify + EncryptedMediaExtensions.Enabled = true; + ExtensionSettings = { + # TODO: Install extensions the NUR instead of from AMO + "uBlock0@raymondhill.net" = { + installation_mode = "force_installed"; + install_url = "https://addons.mozilla.org/firefox/downloads/latest/ublock-origin/latest.xpi"; + }; + # Decentraleyes + "jid1-BoFifL9Vbdl2zQ@jetpack" = { + installation_mode = "normal_installed"; + install_url = "https://addons.mozilla.org/firefox/downloads/latest/decentraleyes/latest.xpi"; + }; + "jid1-MnnxcxisBPnSXQ@jetpack" = { + installation_mode = "normal_installed"; + install_url = "https://addons.mozilla.org/firefox/downloads/latest/privacy-badger17/latest.xpi"; + }; + # Uninstall undeclared extensions + "*" = { + installation_mode = "blocked"; + blocked_install_message = "Extensions are managed by home-manager"; + }; + }; + Preferences = { + # Enable video hardware acceleration + "media.ffmpeg.vaapi.enabled" = { + Value = true; + Status = "default"; + }; + "dom.security.https_only_mode" = { + Value = true; + Status = "locked"; + }; + "browser.ml.chat.page" = { + Value = false; + Status = "Locked"; + }; + }; + PromptForDownloadLocation = true; + # I use an external password manager, so the built in one just bothers me + PasswordManagerEnabled = false; + Permissions = { + Autoplay = { + Allow = [ + "https://www.youtube.com" + "https://sync-tube.de" + ]; + Default = "block-audio-video"; + }; + }; + FirefoxHome.SponsoredTopSites = false; + }; + }; + } + ]; + # Per-user settings + home-manager.users.toast = { + programs.firefox.policies = { + DisablePocket = true; + ExtensionSettings = { + "sponsorBlocker@ajay.app" = { + installation_mode = "normal_installed"; + install_url = "https://addons.mozilla.org/firefox/downloads/latest/sponsorblock/latest.xpi"; + }; + "@testpilot-containers" = { + installation_mode = "normal_installed"; + install_url = "https://addons.mozilla.org/firefox/downloads/latest/multi-account-containers/latest.xpi"; + }; + "{5cce4ab5-3d47-41b9-af5e-8203eea05245}" = { + installation_mode = "normal_installed"; + install_url = "https://addons.mozilla.org/firefox/downloads/latest/control-panel-for-twitter/latest.xpi"; + }; + }; + Preferences = { + "general.smoothScroll.msdPhysics.enabled" = { + Value = true; + Status = "default"; + }; + "browser.ctrlTab.sortByRecentlyUsed" = { + Value = true; + Status = "default"; + }; + # This should be set automatically, but it isn't for some reason + "extensions.webextensions.ExtensionStorageIDB.enabled" = { + Value = false; + Status = "locked"; + }; + }; + }; + programs.firefox.profiles = { + personal = { + name = "Personal"; + id = 0; + isDefault = true; + containersForce = true; + containers = { + work = { + name = "Work"; + id = 1; + icon = "briefcase"; + color = "green"; + }; + }; + extensions.force = true; + extensions.settings = { + "@testpilot-containers".settings = { + onboarding-stage = 8; + "siteContainerMap@@_teams.microsoft.com" = { + userContextId = "1"; + neverAsk = false; + identityMacAddonUUID = "b50e5b1e-6f3b-4245-8eac-5654d889156e"; + }; + "siteContainerMap@@_outlook.office.com" = { + userContextId = "1"; + neverAsk = false; + identityMacAddonUUID = "b50e5b1e-6f3b-4245-8eac-5654d889156e"; + }; + }; + # Control panel for twitter + "{5cce4ab5-3d47-41b9-af5e-8203eea05245}".settings = { + hideForYouTimeline = false; + alwaysUseLatestTweets = false; + retweets = "ignore"; + restoreOtherInteractionLinks = true; + navBaseFontSize = false; + followButtonStyle = "themed"; + hideSidebarContent = true; + }; + }; + }; + }; + }; +} diff --git a/roles/desktop/programs/git.nix b/roles/desktop/programs/git.nix new file mode 100644 index 0000000..90233b0 --- /dev/null +++ b/roles/desktop/programs/git.nix @@ -0,0 +1,7 @@ +{pkgs, ...}: { + home-manager.users.toast = { + programs.git = { + package = pkgs.gitFull; + }; + }; +} diff --git a/roles/desktop/programs/jamesdsp.nix b/roles/desktop/programs/jamesdsp.nix new file mode 100644 index 0000000..0aaa341 --- /dev/null +++ b/roles/desktop/programs/jamesdsp.nix @@ -0,0 +1,7 @@ +{pkgs, ...}: { + home-manager.users.toast = { + home.packages = with pkgs; [ + jamesdsp + ]; + }; +} diff --git a/roles/desktop/programs/keepassxc.nix b/roles/desktop/programs/keepassxc.nix new file mode 100644 index 0000000..a48544a --- /dev/null +++ b/roles/desktop/programs/keepassxc.nix @@ -0,0 +1,34 @@ +{pkgs, ...}: { + home-manager = { + users.toast = { + programs.keepassxc = { + enable = true; + autostart = true; + settings = { + General = { + # Not sure what changing this does, I'll leave it alone + ConfigVersion = 2; + MinimizeAfterUnlock = true; + AutoSaveAfterEveryChange = false; + }; + GUI = { + ApplicationTheme = "classic"; + MinimizeOnStartup = false; + MinimizeOnClose = true; + MinimizeToTray = true; + ShowTrayIcon = true; + # 0 is icons, 1 is text, 2 is text next to icons, 3 is text under icons, and 4 is follow style + ToolButtonStyle = 0; # Would choose 4 but it's too big for a small window + # monochrome-light, monochrome-dark or colorful + TrayIconAppearance = "monochrome-light"; + }; + Security = { + HideNotes = true; + IconDownloadFallback = true; + }; + SSHAgent.Enabled = true; + }; + }; + }; + }; +} diff --git a/roles/desktop/programs/mpv.nix b/roles/desktop/programs/mpv.nix new file mode 100644 index 0000000..d85b058 --- /dev/null +++ b/roles/desktop/programs/mpv.nix @@ -0,0 +1,24 @@ +{pkgs, ...}: { + nixpkgs.overlays = [ + ( + final: prev: { + mpv-unwrapped = prev.mpv-unwrapped.override { + cddaSupport = true; + }; + } + ) + ]; + home-manager.users.toast = { + programs.mpv = { + enable = true; + scripts = with pkgs.mpvScripts; [ + mpris + ]; + config = { + hwdec = "auto"; + cache = true; + cdda-speed = "8"; + }; + }; + }; +} diff --git a/roles/desktop/programs/spotify.nix b/roles/desktop/programs/spotify.nix new file mode 100644 index 0000000..c895f52 --- /dev/null +++ b/roles/desktop/programs/spotify.nix @@ -0,0 +1,5 @@ +{pkgs, ...}: { + home-manager.users.toast = { + home.packages = [pkgs.spotify]; + }; +} diff --git a/roles/desktop/programs/ssh.nix b/roles/desktop/programs/ssh.nix new file mode 100644 index 0000000..8aac072 --- /dev/null +++ b/roles/desktop/programs/ssh.nix @@ -0,0 +1,33 @@ +{...}: { + programs.ssh.knownHosts = { + everest = { + hostNames = [ + "everest.tailscale" + "toast003.xyz" + ]; + publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAqfABZKnF5YYGZTOKuT7m+sOnUqBQSvLke9c3JDsF5s"; + }; + }; + + home-manager.users.toast = { + programs.ssh = { + enable = true; + enableDefaultConfig = false; + matchBlocks = { + "everest" = { + host = "toast003.xyz"; + hostname = "toast003.xyz"; + forwardAgent = true; + sendEnv = ["COLORTERM"]; + port = 69; + }; + "everest-tailscale" = { + host = "everest"; + hostname = "everest.tailscale"; + forwardAgent = true; + sendEnv = ["COLORTERM"]; + }; + }; + }; + }; +} diff --git a/roles/desktop/programs/sysdvr-qt.nix b/roles/desktop/programs/sysdvr-qt.nix new file mode 100644 index 0000000..342830f --- /dev/null +++ b/roles/desktop/programs/sysdvr-qt.nix @@ -0,0 +1,8 @@ +{...}: { + services.udev.extraRules = '' + SUBSYSTEM=="usb", ATTRS{idVendor}=="18d1", ATTRS{idProduct}=="4ee0", MODE="0666" + ''; + home-manager.users.toast.services.flatpak = { + packages = ["io.github.parnassius.SysDVR-Qt"]; + }; +} diff --git a/roles/desktop/services/default.nix b/roles/desktop/services/default.nix new file mode 100644 index 0000000..6bc1d0f --- /dev/null +++ b/roles/desktop/services/default.nix @@ -0,0 +1,11 @@ +{...}: { + imports = [ + ./ssh-agent.nix + ./flatpak.nix + ./syncthing.nix + ./pipewire.nix + ./printing.nix + ./networkmanager.nix + ./tailscale.nix + ]; +} diff --git a/roles/desktop/services/flatpak.nix b/roles/desktop/services/flatpak.nix new file mode 100644 index 0000000..a2f9723 --- /dev/null +++ b/roles/desktop/services/flatpak.nix @@ -0,0 +1,17 @@ +{flakeSelf, ...}: { + services.flatpak.enable = true; + + home-manager = { + sharedModules = [{imports = [flakeSelf.inputs.nix-flatpak.homeManagerModules.nix-flatpak];}]; + users.toast = { + services.flatpak = { + packages = ["tv.plex.PlexDesktop"]; + uninstallUnmanaged = true; + update.auto = { + enable = true; + onCalendar = "weekly"; + }; + }; + }; + }; +} diff --git a/roles/desktop/services/networkmanager.nix b/roles/desktop/services/networkmanager.nix new file mode 100644 index 0000000..f19610b --- /dev/null +++ b/roles/desktop/services/networkmanager.nix @@ -0,0 +1,52 @@ +{ + config, + lib, + flakeSelf, + ... +}: let + tailscaleName = config.services.tailscale.interfaceName; +in { + sops.secrets.wifiPasswords = { + sopsFile = "${flakeSelf.inputs.secrets}/wifi-passwords.env"; + format = "dotenv"; + }; + networking.networkmanager = { + enable = true; + unmanaged = [ + "interface-name:${tailscaleName}" + ]; + ensureProfiles = { + environmentFiles = [config.sops.secrets.wifiPasswords.path]; + profiles = with lib.toast.networkManager; { + "4g-modem" = mkWifiProfile { + id = "4G Modem"; + priority = 5; + ssid = "TP-Link_CCB4"; + wifi-security = { + auth-alg = "open"; + key-mgmt = "wpa-psk"; + psk = "$MODEM"; + }; + }; + phone = mkWifiProfile { + id = "Phone"; + priority = 5; + ssid = "Redmi Note 10 Pro_5197"; + wifi-security = { + auth-alg = "open"; + key-mgmt = "sae"; + psk = "$PHONE"; + }; + }; + home = mkWifiProfile { + id = "Home"; + ssid = "MOVISTAR-WIFI6-DC98"; + wifi-security = { + key-mgmt = "sae"; + psk = "$HOME"; + }; + }; + }; + }; + }; +} diff --git a/roles/desktop/services/pipewire.nix b/roles/desktop/services/pipewire.nix new file mode 100644 index 0000000..f32365b --- /dev/null +++ b/roles/desktop/services/pipewire.nix @@ -0,0 +1,9 @@ +{...}: { + services.pipewire = { + enable = true; + pulse.enable = true; + }; + + # This allows pipewire to get realtime priority, which (hopefully) gets rid of stutters + security.rtkit.enable = true; +} diff --git a/roles/desktop/services/printing.nix b/roles/desktop/services/printing.nix new file mode 100644 index 0000000..49a590d --- /dev/null +++ b/roles/desktop/services/printing.nix @@ -0,0 +1,7 @@ +{...}: { + services.printing = { + enable = true; + startWhenNeeded = true; + stateless = true; + }; +} diff --git a/roles/desktop/services/ssh-agent.nix b/roles/desktop/services/ssh-agent.nix new file mode 100644 index 0000000..6f734f8 --- /dev/null +++ b/roles/desktop/services/ssh-agent.nix @@ -0,0 +1,19 @@ +{...}: { + programs.ssh.startAgent = true; + /* + Home assistant added an option that does this + https://github.com/nix-community/home-manager/commit/2d9210f25ed18d5d4e11e6b886de4027c0c51a94 + but since I still need to fix home-manager's envvars not applying I'll stick to the NixOS one + */ + /* + TODO: fix SSH_AUTH_SOCK not being set in Plasma + Turns out the NixOS module also has issues :3 + The env is set but only in bash, not in the DE, so + keepass can't pick it up. For now I'll just set it manually + */ + home-manager.users.toast.xdg.configFile."plasma-workspace/env/ssh-agent.sh".text = '' + if [[ -z "$SSH_AUTH_SOCK" ]]; then + export SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/ssh-agent + fi + ''; +} diff --git a/roles/desktop/services/syncthing.nix b/roles/desktop/services/syncthing.nix new file mode 100644 index 0000000..249f49d --- /dev/null +++ b/roles/desktop/services/syncthing.nix @@ -0,0 +1,57 @@ +{config, ...}: { + services.syncthing = { + # enable = true; + # user = "toast"; + # group = "users"; + # dataDir = config.users.users.toast.home; + settings.folders."passwords".path = "~/Documents/Passwords"; + }; + age.secrets = { + syncthingCert = { + owner = "toast"; + group = "users"; + }; + syncthingKey = { + owner = "toast"; + group = "users"; + }; + }; + home-manager.users.toast = { + osConfig, + lib, + ... + }: let + systemConfig = osConfig.services.syncthing; + missingOptions = [ + "all_proxy" + "configDir" + "dataDir" + "databaseDir" + "declarative" + "devices" + "folders" + "extraFlags" + "user" + "group" + "systemService" + "openDefaultPorts" + "options" + "relay" + "useInotify" + "guiPasswordFile" + ]; + removeMissingOptions = rawOptions: ( + # lib.attrsets.filterAttrs (n: v: n == "all_proxy") rawOptions + builtins.removeAttrs rawOptions missingOptions + ); + in { + services.syncthing = + removeMissingOptions systemConfig + // { + enable = true; + # Renamed options + allProxy = systemConfig.all_proxy; + extraOptions = systemConfig.extraFlags; + }; + }; +} diff --git a/roles/desktop/services/tailscale.nix b/roles/desktop/services/tailscale.nix new file mode 100644 index 0000000..8235ae8 --- /dev/null +++ b/roles/desktop/services/tailscale.nix @@ -0,0 +1,16 @@ +{pkgs, ...}: { + home-manager.users.toast = { + services.tailscale-systray = { + enable = true; + package = pkgs.tailscale.overrideAttrs { + postPatch = '' + substituteInPlace client/systray/logo.go --replace-fail \ + "color.NRGBA{0, 0, 0, 255}" "color.NRGBA{0, 0, 0, 0}" + ''; + # Only use this for the tray, so no testing is needed + # Makes the build last a lot less too + doCheck = false; + }; + }; + }; +} diff --git a/roles/gaming/default.nix b/roles/gaming/default.nix new file mode 100644 index 0000000..4845419 --- /dev/null +++ b/roles/gaming/default.nix @@ -0,0 +1,16 @@ +{pkgs, ...}: { + imports = [ + ./programs + ./services + ]; + system.replaceDependencies.replacements = [ + { + oldDependency = pkgs.sdl3; + newDependency = pkgs.sdl3.overrideAttrs { + patches = [ + ./sdl-keychron-blacklist.patch + ]; + }; + } + ]; +} diff --git a/roles/gaming/programs/azahar.nix b/roles/gaming/programs/azahar.nix new file mode 100644 index 0000000..2b88164 --- /dev/null +++ b/roles/gaming/programs/azahar.nix @@ -0,0 +1,18 @@ +{pkgs, ...}: { + # nixpkgs.overlays = [ + # ( + # final: prev: { + # azahar = prev.azahar.overrideAttrs (old: { + # version = "2120.3"; + # src = final.fetchzip { + # url = "https://github.com/azahar-emu/azahar/releases/download/2120.3/azahar-unified-source-20250414-00e3bbb.tar.xz"; + # hash = "sha256-3QKicmpmWDM7x9GDJ8sxm2Xu+0Yfho4LkSWMp+ixzRk="; + # }; + # }); + # } + # ) + # ]; + home-manager.users.toast = { + home.packages = [pkgs.azahar]; + }; +} diff --git a/roles/gaming/programs/cemu.nix b/roles/gaming/programs/cemu.nix new file mode 100644 index 0000000..588fd39 --- /dev/null +++ b/roles/gaming/programs/cemu.nix @@ -0,0 +1,7 @@ +{pkgs, ...}: { + home-manager.users.toast = { + home = { + packages = [pkgs.cemu]; + }; + }; +} diff --git a/roles/gaming/programs/default.nix b/roles/gaming/programs/default.nix new file mode 100755 index 0000000..fda1ac9 --- /dev/null +++ b/roles/gaming/programs/default.nix @@ -0,0 +1,15 @@ +{pkgs, ...}: { + imports = [ + ./steam.nix + ./mangohud.nix + ./rpcs3.nix + ./retroarch.nix + ./pcsx2.nix + ./cemu.nix + ./azahar.nix + ]; + environment.systemPackages = with pkgs; [ + heroic + prismlauncher + ]; +} diff --git a/roles/gaming/programs/mangohud.nix b/roles/gaming/programs/mangohud.nix new file mode 100644 index 0000000..91e4675 --- /dev/null +++ b/roles/gaming/programs/mangohud.nix @@ -0,0 +1,13 @@ +{...}: { + home-manager.users.toast = {...}: { + programs.mangohud = { + enable = true; + # This only works for Vulkan, openGL programs still need the mangohud wrapper + enableSessionWide = true; + settings = { + preset = 4; + no_display = true; + }; + }; + }; +} diff --git a/roles/gaming/programs/pcsx2.nix b/roles/gaming/programs/pcsx2.nix new file mode 100644 index 0000000..8cf7898 --- /dev/null +++ b/roles/gaming/programs/pcsx2.nix @@ -0,0 +1,141 @@ +{ + pkgs, + lib, + ... +}: let + pcsx2-bios = pkgs.fetchzip { + url = "https://myrient.erista.me/files/Redump/Sony%20-%20PlayStation%202%20-%20BIOS%20Images%20%28DoM%20Version%29/ps2-0200a-20040614.zip"; + hash = "sha256-wMvswgmsKl+cJl49VlVW84tvU5Jzd+2dl07SOiUDtwA="; + }; + toPcsx2INI = lib.generators.toINI {listsAsDuplicateKeys = true;}; +in { + home-manager.users.toast = { + home.packages = with pkgs; [ + pcsx2 + ]; + xdg.configFile = { + #PCSX2 silently overwrites the symlink so I need to force it's creation + "PCSX2/inis/PCSX2.ini".force = true; + "PCSX2/inis/PCSX2.ini".text = toPcsx2INI { + UI = { + SettingsVersion = 1; + # Use the system theme + Theme = ""; + HideMouseCursor = true; + }; + Folders = { + Bios = "/home/toast/.local/share/PCSX2/bios"; + }; + GameList.RecursivePaths = [ + "/home/toast/Games/PS2/" + ]; + "EmuCore/GS" = { + dithering_ps2 = 1; + upscale_multiplier = 2; + }; + EmuCore = { + EnableDiscordPresence = true; + EnableFastBoot = true; + McdFolderAutoManage = false; + }; + + MemoryCards.Slot1_Filename = "MemoryCard1.ps2"; + + # Controller settings + Pad1 = { + Up = "SDL-0/DPadUp"; + Right = "SDL-0/DPadRight"; + Down = "SDL-0/DPadDown"; + Left = "SDL-0/DPadLeft"; + Triangle = "SDL-0/Y"; + Circle = "SDL-0/B"; + Cross = "SDL-0/A"; + Square = "SDL-0/X"; + Select = "SDL-0/Back"; + Start = "SDL-0/Start"; + L1 = "SDL-0/LeftShoulder"; + L2 = "SDL-0/+LeftTrigger"; + R1 = "SDL-0/RightShoulder"; + R2 = "SDL-0/+RightTrigger"; + L3 = "SDL-0/LeftStick"; + R3 = "SDL-0/RightStick"; + Analog = "SDL-0/Guide"; + LUp = "SDL-0/-LeftY"; + LRight = "SDL-0/+LeftX"; + LDown = "SDL-0/+LeftY"; + LLeft = "SDL-0/-LeftX"; + RUp = "SDL-0/-RightY"; + RRight = "SDL-0/+RightX"; + RDown = "SDL-0/+RightY"; + RLeft = "SDL-0/-RightX"; + LargeMotor = "SDL-0/LargeMotor"; + SmallMotor = "SDL-0/SmallMotor"; + }; + # Default hotkeys + Hotkeys = { + ToggleFullscreen = "Keyboard/Alt & Keyboard/Return"; + CycleAspectRatio = "Keyboard/F6"; + CycleInterlaceMode = "Keyboard/F5"; + CycleMipmapMode = "Keyboard/Insert"; + GSDumpMultiFrame = "Keyboard/Control & Keyboard/Shift & Keyboard/F8"; + Screenshot = "Keyboard/F8"; + GSDumpSingleFrame = "Keyboard/Shift & Keyboard/F8"; + ToggleSoftwareRendering = "Keyboard/F9"; + ZoomIn = "Keyboard/Control & Keyboard/Plus"; + ZoomOut = "Keyboard/Control & Keyboard/Minus"; + InputRecToggleMode = "Keyboard/Shift & Keyboard/R"; + LoadStateFromSlot = "Keyboard/F3"; + SaveStateToSlot = "Keyboard/F1"; + NextSaveStateSlot = "Keyboard/F2"; + PreviousSaveStateSlot = "Keyboard/Shift & Keyboard/F2"; + OpenPauseMenu = "Keyboard/Escape"; + ToggleFrameLimit = "Keyboard/F4"; + TogglePause = "Keyboard/Space"; + ToggleSlowMotion = "Keyboard/Shift & Keyboard/Backtab"; + ToggleTurbo = "Keyboard/Tab"; + HoldTurbo = "Keyboard/Period"; + }; + }; + # 007 nightfire + "PCSX2/gamesettings/SLUS-20579_5B86BB62.ini".text = toPcsx2INI { + "EmuCore/GS".AspectRatio = "16:9"; + }; + "PCSX2/gamesettings/SLUS-21050_BEBF8793.ini".text = toPcsx2INI { + "EmuCore/GS".AspectRatio = "16:9"; + Patches.Enable = [ + "60 FPS for Crashes" + "60 FPS for Menus" + "Progressive Scan" + "MPH to KPH" + "Extra Particles While Driving" + ]; + }; + }; + xdg.dataFile = { + # I would prefer to use symlinkJoin like I do for the ISOs, but + # the bios folder needs to be writable to store the bios settings + "PCSX2/bios/ntsc.bin".source = pkgs.runCommandLocal "pcsx2-bios" {} '' + cp -v ${pcsx2-bios}/*.bin $out + ''; + }; + }; + + # Syncthing + services.syncthing.settings.folders."pcsx2" = { + label = "PCSX2"; + id = "qcdsp-qaaej"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "~/.config/PCSX2"; + }; + home-manager.users.toast.xdg.configFile = { + "PCSX2/.stignore".text = '' + cache + bios + gamesettings + inis/PCSX2.ini* + inis/debuggersettings + inputprofiles + logs + ''; + }; +} diff --git a/roles/gaming/programs/retroarch.nix b/roles/gaming/programs/retroarch.nix new file mode 100644 index 0000000..60f4bb7 --- /dev/null +++ b/roles/gaming/programs/retroarch.nix @@ -0,0 +1,87 @@ +{pkgs, ...}: { + home-manager.users.toast = { + home = { + packages = [ + ( + pkgs.wrapRetroArch { + cores = with pkgs.libretro; [ + snes9x + ]; + settings = { + video_driver = "vulkan"; + video_fullscreen = "true"; + menu_swap_ok_cancel_buttons = "true"; + input_joypad_driver = "sdl2"; + # Enable touchscreen support + menu_pointer_enable = "true"; + + # Folder stuffs + + # System/BIOS files + system_directory = "~/.local/share/retroarch/system"; + # Downloads + core_assets_directory = "~/.local/share/retroarch/downloads"; + thumbnails_directory = "~/.local/share/retroarch/thumbnails"; + content_database_path = "~/.local/share/retroarch/database/rdb"; + cheat_database_path = "~/.local/share/retroarch/cheats"; + video_filter_dir = "~/.local/share/retroarch/filters/video"; + audio_filter_dir = "~/.local/share/retroarch/filters/audio"; + video_shader_dir = "~/.local/share/retroarch/shaders"; + recording_output_directory = "~/.local/share/retroarch/records"; + overlay_directory = "~/.local/share/retroarch/overlays"; + osk_overlay_directory = "~/.local/share/retroarch/overlays/keyboards"; + screenshot_directory = "~/.local/share/retroarch/screenshots"; + playlist_directory = "~/.local/share/retroarch/playlists"; + savefile_directory = "~/.local/share/retroarch/saves"; + savestate_directory = "~/.local/share/retroarch/states"; + log_dir = "~/.local/share/retroarch/logs"; + + # By default settings has some things that this overrides, so I need to set them myself + libretro_info_path = "${pkgs.libretro-core-info}/share/retroarch/cores"; + joypad_autoconfig_dir = "${pkgs.retroarch-joypad-autoconfig}/share/libretro/autoconfig"; + assets_directory = "${pkgs.retroarch-assets}/share/retroarch/assets"; + }; + } + ) + ]; + }; + # Retroarch is dumb since it doesn't generate some folders (but it does for others) + systemd.user.tmpfiles.rules = [ + "d /%h/.local/share/retroarch/playlists" + "d /%h/.local/share/retroarch/saves" + "d /%h/.local/share/retroarch/states" + ]; + systemd.user.paths = { + snes-roms = { + Unit.Description = "Monitor SNES rom path for changes"; + Path = { + PathChanged = "/%h/Games/SNES"; + Unit = "update-retroarch-library.service"; + }; + Install.WantedBy = ["default.target"]; + }; + }; + systemd.user.services.update-retroarch-library = { + Service = { + Type = "oneshot"; + ExecStart = pkgs.writeShellScript "scan-snes-games" '' + ${pkgs.libnotify}/bin/notify-send -a RetroArch \ + -i retroarch \ + "SNES games changed!" \ + "Scanning $TRIGGER_PATH..." + ${pkgs.retroarch}/bin/retroarch --scan "/home/toast/Games/SNES" + ''; + }; + }; + }; + + # Sync saves and some other stuff + services.syncthing.settings.folders = { + "retroarch" = { + label = "RetroArch"; + id = "jxuou-2yjnu"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "~/.local/share/retroarch"; + }; + }; +} diff --git a/roles/gaming/programs/rpcs3.nix b/roles/gaming/programs/rpcs3.nix new file mode 100644 index 0000000..8fe81fa --- /dev/null +++ b/roles/gaming/programs/rpcs3.nix @@ -0,0 +1,15 @@ +{pkgs, ...}: { + environment.systemPackages = with pkgs; [ + rpcs3 + ]; + + # Increase the memory lock limit + security.pam.loginLimits = [ + { + domain = "*"; + item = "memlock"; + type = "-"; # Applies to both hard and soft limits + value = "unlimited"; + } + ]; +} diff --git a/roles/gaming/programs/steam.nix b/roles/gaming/programs/steam.nix new file mode 100644 index 0000000..c6e143f --- /dev/null +++ b/roles/gaming/programs/steam.nix @@ -0,0 +1,34 @@ +{ + config, + pkgs, + ... +}: { + programs.steam = { + enable = true; + localNetworkGameTransfers.openFirewall = true; + # Doubt that I'll use it, but I'll enable it anyways + remotePlay.openFirewall = true; + + extraCompatPackages = with pkgs; [ + proton-ge-bin + ]; + }; + + # Some linux native games (rise of the tomb raider) use alsa for sound + services.pipewire.alsa.enable = + if config.services.pipewire.pulse.enable == true + then true + else false; + + home-manager.users.toast = { + systemd.user.tmpfiles.rules = [ + "r '/%h/.local/share/applications/Steam Linux Runtime *.desktop'" + "r '/%h/.local/share/applications/Proton *.desktop'" + ]; + services.flatpak.packages = [ + # Celeste mod manager + "io.github.everestapi.Olympus" + ]; + home.packages = [pkgs.sgdboop]; + }; +} diff --git a/roles/gaming/sdl-keychron-blacklist.patch b/roles/gaming/sdl-keychron-blacklist.patch new file mode 100644 index 0000000..ef73b54 --- /dev/null +++ b/roles/gaming/sdl-keychron-blacklist.patch @@ -0,0 +1,13 @@ +diff --git a/src/joystick/SDL_joystick.c b/src/joystick/SDL_joystick.c +index 5ce36de86..37bf5ca85 100644 +--- a/src/joystick/SDL_joystick.c ++++ b/src/joystick/SDL_joystick.c +@@ -281,6 +281,8 @@ static Uint32 initial_blacklist_devices[] = { + MAKE_VIDPID(0x3297, 0x1969), // Moonlander MK1 Keyboard + MAKE_VIDPID(0x3434, 0x0211), // Keychron K1 Pro System Control + MAKE_VIDPID(0x04f2, 0xa13c), // HP Deluxe Webcam KQ246AA ++ MAKE_VIDPID(0x3434, 0x0353), // Keychron V5 System Control ++ MAKE_VIDPID(0x3434, 0xd030), // Keychron Link + }; + static SDL_vidpid_list blacklist_devices = { + SDL_HINT_JOYSTICK_BLACKLIST_DEVICES, 0, 0, NULL, diff --git a/roles/gaming/services/default.nix b/roles/gaming/services/default.nix new file mode 100644 index 0000000..24b067d --- /dev/null +++ b/roles/gaming/services/default.nix @@ -0,0 +1,5 @@ +{...}: { + imports = [ + ./syncthing.nix + ]; +} diff --git a/roles/gaming/services/syncthing.nix b/roles/gaming/services/syncthing.nix new file mode 100644 index 0000000..0219bac --- /dev/null +++ b/roles/gaming/services/syncthing.nix @@ -0,0 +1,37 @@ +{...}: { + /* + This file will sync saves for games that don't have cloud saves + TODO: turn this into a module eventually + */ + + services.syncthing.settings.folders = { + "steam-201810" = { + label = "Wolfenstein The New Order Saves"; + id = "laxxf-t2wmy"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "~/.local/share/Steam/steamapps/compatdata/201810/pfx/drive_c/users/steamuser/Saved Games/MachineGames/Wolfenstein The New Order/"; + }; + "project-diva-mods" = { + label = "Project Diva Mods"; + id = "7pscj-6egww"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "~/.local/share/Steam/steamapps/common/Hatsune Miku Project DIVA Mega Mix Plus/mods/"; + }; + "project-eden-saves" = { + label = "Project Eden saves"; + id = "xa3qx-3ax5k"; + devices = ["server" "pc" "winmax2" "steamdeck"]; + path = "~/.local/share/Steam/steamapps/compatdata/1761390/pfx/drive_c/users/steamuser/AppData/Roaming/EDEN/"; + }; + "games" = { + label = "Games"; + id = "mwzph-gf2df"; + devices = ["server" "pc" "winmax2" "steamdeck"]; + path = "~/Games"; + }; + }; + home-manager.users.toast.home.file."steam-201810-ignore" = { + target = ".local/share/Steam/steamapps/compatdata/201810/pfx/drive_c/users/steamuser/Saved Games/MachineGames/Wolfenstein The New Order/.stignore"; + text = "base/qconsole.log\nbase/wolfConfig.cfg"; + }; +} diff --git a/roles/kde/default.nix b/roles/kde/default.nix index fbcf748..f80328a 100755 --- a/roles/kde/default.nix +++ b/roles/kde/default.nix @@ -1,7 +1,8 @@ -{ ... }: - -{ - imports = [ - ./plasma.nix - ]; +{...}: { + imports = [ + ./plasma.nix + ./sddm.nix + ./programs + ./patches + ]; } diff --git a/roles/kde/patches/default.nix b/roles/kde/patches/default.nix new file mode 100644 index 0000000..374881a --- /dev/null +++ b/roles/kde/patches/default.nix @@ -0,0 +1,29 @@ +{lib, ...}: let + rootDirs = builtins.readDir ./.; + removeFiles = lib.attrsets.filterAttrs (n: v: v == "directory") rootDirs; + programsToPatch = builtins.attrNames removeFiles; + + bigOverlay = final: prev: + lib.attrsets.mergeAttrsList ( + lib.lists.forEach programsToPatch ( + program: let + unpatchedProgram = prev."${program}"; + newPatches = lib.toast.patches.patchesInPath (lib.path.append ./. program); + in { + "${program}" = unpatchedProgram.overrideAttrs { + version = "${unpatchedProgram.version}-patched"; + __intentionallyOverridingVersion = true; + patches = unpatchedProgram.patches ++ newPatches; + }; + } + ) + ); +in { + nixpkgs.overlays = [ + ( + final: prev: { + kdePackages = prev.kdePackages.overrideScope bigOverlay; + } + ) + ]; +} diff --git a/roles/kde/patches/plasma-desktop/patches.txt b/roles/kde/patches/plasma-desktop/patches.txt new file mode 100644 index 0000000..84f4a14 --- /dev/null +++ b/roles/kde/patches/plasma-desktop/patches.txt @@ -0,0 +1,6 @@ +Plasma 6.6.0: + +Pr 3256 https://invent.kde.org/plasma/plasma-desktop/-/merge_requests/3256 +Pr 3259 https://invent.kde.org/plasma/plasma-desktop/-/merge_requests/3259 +Pr 3269 https://invent.kde.org/plasma/plasma-desktop/-/merge_requests/3269 +Pr 3356 https://invent.kde.org/plasma/plasma-desktop/-/merge_requests/3356 diff --git a/roles/kde/patches/plasma-desktop/pr3256.patch b/roles/kde/patches/plasma-desktop/pr3256.patch new file mode 100644 index 0000000..61d2a0e --- /dev/null +++ b/roles/kde/patches/plasma-desktop/pr3256.patch @@ -0,0 +1,94 @@ +From 79d010f4bcdb19d5a19d222dd3c3cb581fde891d Mon Sep 17 00:00:00 2001 +From: David Redondo +Date: Fri, 17 Oct 2025 15:15:51 +0200 +Subject: [PATCH] applets/taskmanager: Allow changing a tasks volume by + scrolling + +FEATURE:510668 +FIXED-IN:6.6 +--- + .../package/contents/config/main.xml | 1 + + .../package/contents/ui/ConfigBehavior.qml | 3 ++- + .../package/contents/ui/MouseHandler.qml | 20 +++++++++++++++++++ + 3 files changed, 23 insertions(+), 1 deletion(-) + +diff --git a/applets/taskmanager/package/contents/config/main.xml b/applets/taskmanager/package/contents/config/main.xml +index f71a7a8457..8c3b68785b 100644 +--- a/applets/taskmanager/package/contents/config/main.xml ++++ b/applets/taskmanager/package/contents/config/main.xml +@@ -90,6 +90,7 @@ + + + ++ + + 0 + +diff --git a/applets/taskmanager/package/contents/ui/ConfigBehavior.qml b/applets/taskmanager/package/contents/ui/ConfigBehavior.qml +index 2c0dc19154..73357e9dcd 100644 +--- a/applets/taskmanager/package/contents/ui/ConfigBehavior.qml ++++ b/applets/taskmanager/package/contents/ui/ConfigBehavior.qml +@@ -202,13 +202,14 @@ KCMUtils.SimpleKCM { + + QQC2.ComboBox { + id: wheelEnabled +- Kirigami.FormData.label: i18nc("@label:listbox Part of a sentence: 'Scrolling behavior does nothing/cycles through tasks/cycles through the selected task's windows'", "Scrolling behavior:") ++ Kirigami.FormData.label: i18nc("@label:listbox Part of a sentence: 'Scrolling behavior does nothing/cycles through tasks/cycles through the selected task's windows/adjusts the hovered task’s volume''", "Scrolling behavior:") + Layout.fillWidth: true + Layout.minimumWidth: Kirigami.Units.gridUnit * 14 + model: [ + i18nc("@item:inlistbox Part of a sentence: 'Scrolling behavior does nothing'", "Does nothing"), + i18nc("@item:inlistbox Part of a sentence: 'Scrolling behavior cycles through all tasks'", "Cycles through all tasks"), + i18nc("@item:inlistbox Part of a sentence: 'Scrolling behavior cycles through the hovered task's windows'", "Cycles through the hovered task’s windows"), ++ i18nc("@item:inlistbox Part of a sentence: 'Scrolling behavior adjusts the hovered task’s volume'", "Adjusts the hovered task’s volume"), + ] + } + +diff --git a/applets/taskmanager/package/contents/ui/MouseHandler.qml b/applets/taskmanager/package/contents/ui/MouseHandler.qml +index cf2362835a..3f8d703d73 100644 +--- a/applets/taskmanager/package/contents/ui/MouseHandler.qml ++++ b/applets/taskmanager/package/contents/ui/MouseHandler.qml +@@ -8,6 +8,7 @@ import QtQuick + + import org.kde.taskmanager as TaskManager + import org.kde.plasma.plasmoid ++import org.kde.plasma.private.volume as PlasmaPa + + import "code/tools.js" as TaskTools + +@@ -157,6 +158,10 @@ DropArea { + } + } + ++ PlasmaPa.GlobalConfig { ++ id: plasmaPaConfig ++ } ++ + WheelHandler { + id: wheelHandler + +@@ -179,6 +184,21 @@ DropArea { + increment--; + } + const anchor = dropArea.target.childAt(event.x, event.y); ++ if (Plasmoid.configuration.wheelEnabled === 3) { ++ const loudest = anchor?.audioStreams?.reduce((loudest, stream) => Math.max(loudest, stream.volume), 0) ++ const step = (pulseAudio.item.normalVolume - pulseAudio.item.minimalVolume) * plasmaPaConfig.volumeStep / 100; ++ anchor?.audioStreams?.forEach((stream) => { ++ let delta = step * increment; ++ if (loudest > 0) { ++ delta *= stream.volume / loudest; ++ } ++ const volume = stream.volume + delta; ++ console.log(volume, Math.max(pulseAudio.item.minimalVolume, Math.min(volume, pulseAudio.item.normalVolume))); ++ stream.model.Volume = Math.max(pulseAudio.item.minimalVolume, Math.min(volume, pulseAudio.item.normalVolume)); ++ stream.model.Muted = volume === 0 ++ }) ++ return; ++ } + while (increment !== 0) { + TaskTools.activateNextPrevTask(anchor, increment < 0, Plasmoid.configuration.wheelSkipMinimized, Plasmoid.configuration.wheelEnabled, tasks); + increment += (increment < 0) ? 1 : -1; +-- +GitLab + diff --git a/roles/kde/patches/plasma-desktop/pr3259.patch b/roles/kde/patches/plasma-desktop/pr3259.patch new file mode 100644 index 0000000..8104e45 --- /dev/null +++ b/roles/kde/patches/plasma-desktop/pr3259.patch @@ -0,0 +1,78 @@ +From 57885ba4ec524bdc1c1326228f27c1c3a3561bba Mon Sep 17 00:00:00 2001 +From: Nate Graham +Date: Tue, 21 Oct 2025 13:39:40 -0600 +Subject: [PATCH] applets/kickoff: add spacing between non-switch-on-hover + category items + +Otherwise, their highlight effects touch, and it looks bad. + +To avoid blowing up the layout as a result of this change, slightly +decrease the height of these category list items too, which also reduces +some code complexity. + +BUG: 508985 +FIXED-IN: 6.6.0 +--- + applets/kickoff/ApplicationsPage.qml | 1 + + applets/kickoff/KickoffListDelegate.qml | 9 --------- + applets/kickoff/KickoffListView.qml | 5 +++++ + 3 files changed, 6 insertions(+), 9 deletions(-) + +diff --git a/applets/kickoff/ApplicationsPage.qml b/applets/kickoff/ApplicationsPage.qml +index c2baa75b52..fe8d6eaafb 100644 +--- a/applets/kickoff/ApplicationsPage.qml ++++ b/applets/kickoff/ApplicationsPage.qml +@@ -20,6 +20,7 @@ BasePage { + id: sideBar + focus: true // needed for Loaders + model: kickoff.rootModel ++ showingCategories: true + // needed otherwise app displayed at top-level will show a first character as group. + section.property: "" + delegate: KickoffListDelegate { +diff --git a/applets/kickoff/KickoffListDelegate.qml b/applets/kickoff/KickoffListDelegate.qml +index b1f8afb3ce..02bfcfcacd 100644 +--- a/applets/kickoff/KickoffListDelegate.qml ++++ b/applets/kickoff/KickoffListDelegate.qml +@@ -72,15 +72,6 @@ AbstractKickoffItemDelegate { + id: label + Layout.fillWidth: !descriptionLabel.visible + Layout.maximumWidth: root.width - root.leftPadding - root.rightPadding - icon.width - row.spacing +- Layout.preferredHeight: { +- if (root.isCategoryListItem) { +- return root.compact ? implicitHeight : Math.round(implicitHeight * 1.5); +- } +- if (!root.compact && !descriptionLabel.visible) { +- return implicitHeight + descriptionLabel.implicitHeight +- } +- return implicitHeight; +- } + text: root.text + textFormat: root.isMultilineText ? Text.StyledText : Text.PlainText + elide: Text.ElideRight +diff --git a/applets/kickoff/KickoffListView.qml b/applets/kickoff/KickoffListView.qml +index c7787493e0..382d146428 100644 +--- a/applets/kickoff/KickoffListView.qml ++++ b/applets/kickoff/KickoffListView.qml +@@ -33,6 +33,7 @@ EmptyPage { + property alias section: view.section + property alias highlight: view.highlight + property alias view: view ++ property bool showingCategories: false + + property bool mainContentView: false + property bool hasSectionView: false +@@ -144,6 +145,10 @@ EmptyPage { + width: view.availableWidth + } + ++ // Without switch-on-hover, it's possible for the selected category and the hovered category to be adjacent. ++ // When this happens, their highlights tuoch and look ungly without some artificial spacing added. ++ spacing: root.showingCategories && !Plasmoid.configuration.switchCategoryOnHover ? Kirigami.Units.smallSpacing : 0 ++ + section { + property: "group" + criteria: ViewSection.FullString +-- +GitLab + diff --git a/roles/kde/patches/plasma-desktop/pr3269.patch b/roles/kde/patches/plasma-desktop/pr3269.patch new file mode 100644 index 0000000..3a53322 --- /dev/null +++ b/roles/kde/patches/plasma-desktop/pr3269.patch @@ -0,0 +1,66 @@ +From a63fce38f285b59407c24f44639023d41b5d3ca9 Mon Sep 17 00:00:00 2001 +From: Nate Graham +Date: Thu, 30 Oct 2025 20:01:32 -0600 +Subject: [PATCH] applets/kickoff: add separation between Places page list + items too + +Missed in 57885ba4ec524bdc1c1326228f27c1c3a3561bba + +BUG: 508985 +FIXED-IN: 6.6.0 +--- + applets/kickoff/ApplicationsPage.qml | 2 +- + applets/kickoff/KickoffListView.qml | 4 ++-- + applets/kickoff/PlacesPage.qml | 1 + + 3 files changed, 4 insertions(+), 3 deletions(-) + +diff --git a/applets/kickoff/ApplicationsPage.qml b/applets/kickoff/ApplicationsPage.qml +index fe8d6eaafb..206ddd958d 100644 +--- a/applets/kickoff/ApplicationsPage.qml ++++ b/applets/kickoff/ApplicationsPage.qml +@@ -20,7 +20,7 @@ BasePage { + id: sideBar + focus: true // needed for Loaders + model: kickoff.rootModel +- showingCategories: true ++ isSidebar: true + // needed otherwise app displayed at top-level will show a first character as group. + section.property: "" + delegate: KickoffListDelegate { +diff --git a/applets/kickoff/KickoffListView.qml b/applets/kickoff/KickoffListView.qml +index 382d146428..2d67bd59b7 100644 +--- a/applets/kickoff/KickoffListView.qml ++++ b/applets/kickoff/KickoffListView.qml +@@ -33,7 +33,7 @@ EmptyPage { + property alias section: view.section + property alias highlight: view.highlight + property alias view: view +- property bool showingCategories: false ++ property bool isSidebar: false + + property bool mainContentView: false + property bool hasSectionView: false +@@ -147,7 +147,7 @@ EmptyPage { + + // Without switch-on-hover, it's possible for the selected category and the hovered category to be adjacent. + // When this happens, their highlights tuoch and look ungly without some artificial spacing added. +- spacing: root.showingCategories && !Plasmoid.configuration.switchCategoryOnHover ? Kirigami.Units.smallSpacing : 0 ++ spacing: root.isSidebar && !Plasmoid.configuration.switchCategoryOnHover ? Kirigami.Units.smallSpacing : 0 + + section { + property: "group" +diff --git a/applets/kickoff/PlacesPage.qml b/applets/kickoff/PlacesPage.qml +index ff92f535e4..7f81096834 100644 +--- a/applets/kickoff/PlacesPage.qml ++++ b/applets/kickoff/PlacesPage.qml +@@ -19,6 +19,7 @@ BasePage { + id: sideBar + focus: true // needed for Loaders + model: placesCategoryModel ++ isSidebar: true + delegate: KickoffListDelegate { + url: "" + description: "" +-- +GitLab + diff --git a/roles/kde/patches/plasma-desktop/pr3356.patch b/roles/kde/patches/plasma-desktop/pr3356.patch new file mode 100644 index 0000000..3d47aca --- /dev/null +++ b/roles/kde/patches/plasma-desktop/pr3356.patch @@ -0,0 +1,37 @@ +From 04c747a41d8c01248c7baeec5c852c2298f76fc3 Mon Sep 17 00:00:00 2001 +From: Christoph Wolk +Date: Mon, 1 Dec 2025 14:36:09 +0100 +Subject: [PATCH] applets/kickoff: also close when triggering from footer menu + +Kickoff already closes when one of the power/session buttons from the +menu is pressed, but the same does not happen if the same action is +shown in the overflow menu. + +Instead, also close kickoff in that case. + +CCBUG: 508725 +--- + applets/kickoff/LeaveButtons.qml | 7 ++++++- + 1 file changed, 6 insertions(+), 1 deletion(-) + +diff --git a/applets/kickoff/LeaveButtons.qml b/applets/kickoff/LeaveButtons.qml +index c9a14dc3254..acf83639341 100644 +--- a/applets/kickoff/LeaveButtons.qml ++++ b/applets/kickoff/LeaveButtons.qml +@@ -214,7 +214,12 @@ RowLayout { + + text: model.display + icon: model.decoration +- onClicked: filteredMenuItemsModel.trigger(index) ++ onClicked: { ++ filteredMenuItemsModel.trigger(index) ++ if (kickoff.hideOnWindowDeactivate) { ++ kickoff.expanded = false; ++ } ++ } + } + onObjectAdded: (index, object) => contextMenu.addMenuItem(object) + onObjectRemoved: (index, object) => contextMenu.removeMenuItem(object) +-- +GitLab + diff --git a/roles/kde/patches/spectacle/patches.txt b/roles/kde/patches/spectacle/patches.txt new file mode 100644 index 0000000..e11d502 --- /dev/null +++ b/roles/kde/patches/spectacle/patches.txt @@ -0,0 +1,5 @@ +Pr 462 https://invent.kde.org/plasma/spectacle/-/merge_requests/462 +Pr 487 https://invent.kde.org/plasma/spectacle/-/merge_requests/487 + +Plasma 6.6.0: +Pr 493 https://invent.kde.org/plasma/spectacle/-/merge_requests/493 diff --git a/roles/kde/patches/spectacle/pr462.patch b/roles/kde/patches/spectacle/pr462.patch new file mode 100644 index 0000000..3db47e0 --- /dev/null +++ b/roles/kde/patches/spectacle/pr462.patch @@ -0,0 +1,2844 @@ +From 9ab7593321d014ff63ef12590a0c2d0e721a90f1 Mon Sep 17 00:00:00 2001 +From: Jhair Paris +Date: Sat, 7 Jun 2025 19:56:28 -0500 +Subject: [PATCH 1/3] Add OCR dependencies and build configuration + +- Add Tesseract and Leptonica dependencies +- Configure OCR support in CMake build system +--- + CMakeLists.txt | 7 +++++++ + src/CMakeLists.txt | 5 ++++- + 2 files changed, 11 insertions(+), 1 deletion(-) + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index f62a38443..3038f472c 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -89,12 +89,19 @@ find_package(PlasmaWaylandProtocols REQUIRED) + find_package(LayerShellQt REQUIRED) + find_package(KPipeWire) + find_package(OpenCV 4.7 REQUIRED core imgproc) ++find_package(PkgConfig REQUIRED) ++pkg_check_modules(TESSERACT REQUIRED tesseract) + + set_package_properties(KPipeWire PROPERTIES DESCRIPTION + "Used to record pipewire streams into a file" + TYPE REQUIRED + ) + ++set_package_properties(TESSERACT PROPERTIES DESCRIPTION ++ "OCR (Optical Character Recognition) engine for text recognition in images" ++ TYPE REQUIRED ++) ++ + # optional components + find_package(KF6DocTools ${KF6_MIN_VERSION}) + set_package_properties(KF6DocTools PROPERTIES DESCRIPTION +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index d27c2dba4..cb000b35d 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -25,6 +25,7 @@ target_sources(spectacle PRIVATE + CommandLineOptions.cpp + ExportManager.cpp + Geometry.cpp ++ OcrManager.cpp + Gui/Annotations/AnnotationDocument.cpp + Gui/Annotations/AnnotationTool.cpp + Gui/Annotations/AnnotationViewport.cpp +@@ -104,7 +105,7 @@ ki18n_wrap_ui(spectacle + # Needed to compile with OpenCV + target_compile_options (spectacle PRIVATE -fexceptions) + +-target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS}) ++target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS} ${TESSERACT_INCLUDE_DIRS}) + + target_link_libraries(spectacle PRIVATE + Qt::Concurrent +@@ -135,6 +136,7 @@ target_link_libraries(spectacle PRIVATE + Wayland::Client + LayerShellQt::Interface + ${OpenCV_LIBRARIES} ++ ${TESSERACT_LIBRARIES} + ) + + # qt_add_qml_module doesn't know how to deal with headers in subdirectories so +@@ -180,6 +182,7 @@ qt_target_qml_sources(spectacle + Gui/InlineMessageList.qml + Gui/Magnifier.qml + Gui/NewScreenshotToolButton.qml ++ Gui/OcrAction.qml + Gui/OptionsMenuButton.qml + Gui/Outline.qml + Gui/QmlUtils.qml +-- +GitLab + + +From ae7a749c89892c8f0d5494c2d7157970578b8b3f Mon Sep 17 00:00:00 2001 +From: Jhair Paris +Date: Sat, 7 Jun 2025 19:57:10 -0500 +Subject: [PATCH 2/3] Implement OcrManager class for text recognition + +- Add OcrManager class with Tesseract integration +- Provide async OCR processing methods +- Handle OCR initialization and cleanup + +Add OCR language selection to General Options + +- Introduced a new combo box for selecting the OCR language in the settings dialog. +- Implemented methods to populate and refresh the OCR language options based on availability. + +Add OCR action and integrate into UI toolbars + +- Create OcrAction.qml for text recognition functionality +- Add OCR buttons to CaptureOverlay toolbars +- Add OCR button to ViewerPage main toolbar + +Integrate OCR functionality into capture and viewer windows + +Add OCR notifications and core integration + +- Add OCR success/error notification events to notifyrc +- Integrate OCR manager in SpectacleCore + +Remove manual translations + +Implement OCR availability checks + +Enhance OcrManager to load Tesseract library dynamically and check its availability. + +Show info cursor on OCR tooltip icon in settings + +Refactor OCR language name handling using QLocale + +- Replace hardcoded/translatable language name map with dynamic lookup via QLocale and scriptToString. + +Refactor Tesseract initialization to support dynamic language detection + +Detect Tesseract and language packs at configure time; link directly to libtesseract + +- Add tesseract_test.cpp using TessBaseAPI::GetAvailableLanguagesAsVector() +- CMake: pkg_check_modules(TESSERACT) + try_run() to check usable langpacks + - Define HAVE_TESSERACT_OCR when successful; otherwise warn and disable OCR +- OCR: refactor OcrManager to use tesseract::TessBaseAPI (C++ API) + - Remove QLibrary-based dynamic loading and manual symbol resolution + - Wrap OCR code with #ifdef HAVE_TESSERACT_OCR and provide graceful fallbacks + +Refactor OCR text recognition to use ResultIterator for improved accuracy + +Refactor the OCR core: centralize extraction in SpectacleCore, remove direct OCR handling from windows. +--- + CMakeLists.txt | 48 +- + cmake/tesseract_test.cpp | 40 + + desktop/spectacle.notifyrc | 5 + + src/CMakeLists.txt | 9 +- + src/Config.h.in | 3 + + src/Gui/CaptureOverlay.qml | 9 + + src/Gui/CaptureWindow.cpp | 4 +- + src/Gui/OcrAction.qml | 14 + + src/Gui/SettingsDialog/GeneralOptions.ui | 127 ++++ + src/Gui/SettingsDialog/GeneralOptionsPage.cpp | 80 ++ + src/Gui/SettingsDialog/GeneralOptionsPage.h | 4 + + src/Gui/SettingsDialog/SettingsDialog.cpp | 5 + + src/Gui/SettingsDialog/spectacle.kcfg | 4 + + src/Gui/ViewerPage.qml | 5 + + src/Gui/ViewerWindow.cpp | 2 +- + src/OcrManager.cpp | 716 ++++++++++++++++++ + src/OcrManager.h | 175 +++++ + src/SpectacleCore.cpp | 143 ++++ + src/SpectacleCore.h | 9 + + 19 files changed, 1393 insertions(+), 9 deletions(-) + create mode 100644 cmake/tesseract_test.cpp + create mode 100644 src/Gui/OcrAction.qml + create mode 100644 src/OcrManager.cpp + create mode 100644 src/OcrManager.h + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index 3038f472c..9b3c47fbe 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -92,16 +92,54 @@ find_package(OpenCV 4.7 REQUIRED core imgproc) + find_package(PkgConfig REQUIRED) + pkg_check_modules(TESSERACT REQUIRED tesseract) + ++# Find Tesseract for OCR functionality ++find_package(PkgConfig QUIET) ++if(PkgConfig_FOUND) ++ pkg_check_modules(TESSERACT tesseract) ++ ++ if(TESSERACT_FOUND) ++ # Test if Tesseract has usable language packs ++ try_run( ++ TESSERACT_TEST_RUN_RESULT ++ TESSERACT_TEST_COMPILE_RESULT ++ ${CMAKE_CURRENT_BINARY_DIR} ++ ${CMAKE_CURRENT_SOURCE_DIR}/cmake/tesseract_test.cpp ++ LINK_LIBRARIES ${TESSERACT_LIBRARIES} ++ CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${TESSERACT_INCLUDE_DIRS}" ++ COMPILE_OUTPUT_VARIABLE TESSERACT_COMPILE_OUTPUT ++ RUN_OUTPUT_VARIABLE TESSERACT_RUN_OUTPUT ++ ) ++ ++ if(TESSERACT_TEST_COMPILE_RESULT AND TESSERACT_TEST_RUN_RESULT EQUAL 0) ++ message(STATUS "Tesseract OCR support enabled") ++ message(STATUS "${TESSERACT_RUN_OUTPUT}") ++ set(HAVE_TESSERACT_OCR TRUE) ++ else() ++ message(WARNING "Tesseract library found but no usable language packs detected") ++ message(WARNING "${TESSERACT_RUN_OUTPUT}") ++ message(WARNING "OCR functionality will be disabled. Install language data packages (e.g., tesseract-ocr-eng)") ++ set(HAVE_TESSERACT_OCR FALSE) ++ endif() ++ else() ++ message(STATUS "Tesseract not found - OCR functionality disabled") ++ set(HAVE_TESSERACT_OCR FALSE) ++ endif() ++ ++ set_package_properties(TESSERACT PROPERTIES ++ DESCRIPTION "OCR engine for text recognition in screenshots" ++ TYPE OPTIONAL ++ PURPOSE "Enables optical character recognition functionality" ++ ) ++else() ++ message(STATUS "PkgConfig not found - Tesseract detection disabled") ++ set(HAVE_TESSERACT_OCR FALSE) ++endif() ++ + set_package_properties(KPipeWire PROPERTIES DESCRIPTION + "Used to record pipewire streams into a file" + TYPE REQUIRED + ) + +-set_package_properties(TESSERACT PROPERTIES DESCRIPTION +- "OCR (Optical Character Recognition) engine for text recognition in images" +- TYPE REQUIRED +-) +- + # optional components + find_package(KF6DocTools ${KF6_MIN_VERSION}) + set_package_properties(KF6DocTools PROPERTIES DESCRIPTION +diff --git a/cmake/tesseract_test.cpp b/cmake/tesseract_test.cpp +new file mode 100644 +index 000000000..4ebae9779 +--- /dev/null ++++ b/cmake/tesseract_test.cpp +@@ -0,0 +1,40 @@ ++#include ++#include ++#include ++#include ++ ++int main() ++{ ++ tesseract::TessBaseAPI api; ++ ++ if (api.Init(nullptr, nullptr) != 0) { ++ std::cerr << "Failed to initialize Tesseract" << std::endl; ++ return 1; ++ } ++ ++ std::vector languages; ++ api.GetAvailableLanguagesAsVector(&languages); ++ ++ // Filter out 'osd' as it's not a usable language for OCR ++ std::vector usableLanguages; ++ for (const auto &lang : languages) { ++ if (lang != "osd") { ++ usableLanguages.push_back(lang); ++ } ++ } ++ ++ if (usableLanguages.empty()) { ++ std::cerr << "No usable Tesseract language packs found. Install language data files (e.g., tesseract-ocr-eng)" << std::endl; ++ return 1; ++ } ++ ++ std::cout << "Found " << usableLanguages.size() << " Tesseract language pack(s): "; ++ for (size_t i = 0; i < usableLanguages.size(); ++i) { ++ std::cout << usableLanguages[i]; ++ if (i < usableLanguages.size() - 1) ++ std::cout << ", "; ++ } ++ std::cout << std::endl; ++ ++ return 0; ++} +diff --git a/desktop/spectacle.notifyrc b/desktop/spectacle.notifyrc +index 5c4166f0b..f3f65f679 100644 +--- a/desktop/spectacle.notifyrc ++++ b/desktop/spectacle.notifyrc +@@ -306,3 +306,8 @@ Comment[uk]=Було створено і збережено новий запи + Comment[zh_CN]=已录制并保存新的屏幕录像 + Comment[zh_TW]=新的螢幕錄製已擷取並儲存 + Action=Popup ++ ++[Event/ocrTextExtracted] ++Name=Text Extracted ++Comment=Text has been extracted from image using OCR ++Action=Popup +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index cb000b35d..c57535e34 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -105,7 +105,7 @@ ki18n_wrap_ui(spectacle + # Needed to compile with OpenCV + target_compile_options (spectacle PRIVATE -fexceptions) + +-target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS} ${TESSERACT_INCLUDE_DIRS}) ++target_include_directories(spectacle PUBLIC ${OpenCV_INCLUDE_DIRS}) + + target_link_libraries(spectacle PRIVATE + Qt::Concurrent +@@ -136,9 +136,14 @@ target_link_libraries(spectacle PRIVATE + Wayland::Client + LayerShellQt::Interface + ${OpenCV_LIBRARIES} +- ${TESSERACT_LIBRARIES} + ) + ++# Link against Tesseract when OCR support is enabled ++if(HAVE_TESSERACT_OCR) ++ target_include_directories(spectacle PRIVATE ${TESSERACT_INCLUDE_DIRS}) ++ target_link_libraries(spectacle PRIVATE ${TESSERACT_LIBRARIES}) ++endif() ++ + # qt_add_qml_module doesn't know how to deal with headers in subdirectories so + # make sure to add those so the headers can be found. + target_include_directories(spectacle PRIVATE +diff --git a/src/Config.h.in b/src/Config.h.in +index 15313542a..aadb22252 100644 +--- a/src/Config.h.in ++++ b/src/Config.h.in +@@ -7,6 +7,9 @@ + /* Define to 1 if we have Purpose */ + #cmakedefine PURPOSE_FOUND 1 + ++/* Define to 1 if we have Tesseract OCR */ ++#cmakedefine HAVE_TESSERACT_OCR 1 ++ + /* Set the Spectacle version from CMake */ + #cmakedefine SPECTACLE_VERSION "@SPECTACLE_VERSION@" + +diff --git a/src/Gui/CaptureOverlay.qml b/src/Gui/CaptureOverlay.qml +index d9ca9a11c..37f3dcf85 100644 +--- a/src/Gui/CaptureOverlay.qml ++++ b/src/Gui/CaptureOverlay.qml +@@ -506,6 +506,11 @@ MouseArea { + visible: action.enabled + action: CopyImageAction {} + } ++ ToolButton { ++ display: TtToolButton.IconOnly ++ visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable ++ action: OcrAction {} ++ } + ExportMenuButton { + focusPolicy: Qt.NoFocus + } +@@ -532,6 +537,10 @@ MouseArea { + visible: action.enabled + action: CopyImageAction {} + } ++ ToolButton { ++ visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable ++ action: OcrAction {} ++ } + ExportMenuButton { + focusPolicy: Qt.NoFocus + } +diff --git a/src/Gui/CaptureWindow.cpp b/src/Gui/CaptureWindow.cpp +index fc4509cf3..cb8ce97ab 100644 +--- a/src/Gui/CaptureWindow.cpp ++++ b/src/Gui/CaptureWindow.cpp +@@ -8,11 +8,13 @@ + #include "CaptureWindow.h" + + #include "Config.h" +-#include "SpectacleCore.h" + #include "Gui/SelectionEditor.h" ++#include "SpectacleCore.h" + + #include + #include ++#include ++#include + + using namespace Qt::StringLiterals; + +diff --git a/src/Gui/OcrAction.qml b/src/Gui/OcrAction.qml +new file mode 100644 +index 000000000..f887ec0ee +--- /dev/null ++++ b/src/Gui/OcrAction.qml +@@ -0,0 +1,14 @@ ++/* SPDX-FileCopyrightText: 2025 Jhair Paris ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++import org.kde.spectacle.private ++ ++T.Action { ++ // OCR is only available for screenshots, not videos, and only when OCR is properly available ++ enabled: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable ++ icon.name: "document-scan" ++ text: i18nc("@action", "Extract Text") ++ onTriggered: contextWindow.extractText() ++} +diff --git a/src/Gui/SettingsDialog/GeneralOptions.ui b/src/Gui/SettingsDialog/GeneralOptions.ui +index 1d99e9a33..ddbbf3e5a 100644 +--- a/src/Gui/SettingsDialog/GeneralOptions.ui ++++ b/src/Gui/SettingsDialog/GeneralOptions.ui +@@ -239,6 +239,132 @@ + + + ++ ++ ++ ++ Qt::Vertical ++ ++ ++ QSizePolicy::Fixed ++ ++ ++ ++ 10 ++ 10 ++ ++ ++ ++ ++ ++ ++ ++ Text Recognition (OCR) ++ ++ ++ ++ ++ ++ ++ Language: ++ ++ ++ ++ ++ ++ ++ ++ 0 ++ 0 ++ ++ ++ ++ currentData ++ ++ ++ ++ ++ ++ ++ false ++ ++ ++ ++ 0 ++ 0 ++ ++ ++ ++ ++ 0 ++ ++ ++ 6 ++ ++ ++ ++ ++ OCR functionality is not available ++ ++ ++ ++ 0 ++ 0 ++ ++ ++ ++ false ++ ++ ++ ++ ++ ++ ++ Please install the required packages: ++• tesseract ++• tesseract language data (e.g., tesseract-ocr-eng for English) ++ ++ ++ ++ 0 ++ 0 ++ ++ ++ ++ Qt::AlignCenter ++ ++ ++ ++ 16 ++ 16 ++ ++ ++ ++ ++ 16 ++ 16 ++ ++ ++ ++ true ++ ++ ++ ++ ++ ++ ++ Qt::Horizontal ++ ++ ++ ++ 40 ++ 20 ++ ++ ++ ++ ++ ++ ++ + + + +@@ -257,6 +383,7 @@ + kcfg_useReleaseToCapture + kcfg_showCaptureInstructions + kcfg_rememberSelectionRect ++ kcfg_ocrLanguage + + + +diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +index fcea6f671..5b8a5d9fc 100644 +--- a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp ++++ b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +@@ -1,4 +1,5 @@ + /* ++ * SPDX-FileCopyrightText: 2025 Jhair Paris + * SPDX-FileCopyrightText: 2019 David Redondo + * SPDX-FileCopyrightText: 2015 Boudhayan Gupta + * +@@ -9,10 +10,13 @@ + + #include "settings.h" + #include "ui_GeneralOptions.h" ++#include "OcrManager.h" + + #include ++#include + + #include ++#include + + GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + : QWidget(parent) +@@ -20,8 +24,16 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + { + m_ui->setupUi(this); + ++ m_ui->ocrInfoIcon->setPixmap(QIcon::fromTheme(QStringLiteral("help-hint")).pixmap(16, 16)); ++ m_ui->ocrInfoIcon->setCursor(Qt::WhatsThisCursor); ++ + m_ui->runningTitle->setLevel(2); + m_ui->regionTitle->setLevel(2); ++ m_ui->ocrTitle->setLevel(2); ++ ++ setupOcrLanguageComboBox(); ++ ++ connect(OcrManager::instance(), &OcrManager::statusChanged, this, &GeneralOptionsPage::refreshOcrLanguageSettings); + + //On Wayland we can't programmatically raise and focus the window so we have to hide the option + if (KWindowSystem::isPlatformWayland() || qstrcmp(qgetenv("XDG_SESSION_TYPE").constData(), "wayland") == 0) { +@@ -31,4 +43,72 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + + GeneralOptionsPage::~GeneralOptionsPage() = default; + ++void GeneralOptionsPage::setupOcrLanguageComboBox() ++{ ++ OcrManager *ocrManager = OcrManager::instance(); ++ ++ if (!ocrManager->isAvailable()) { ++ m_ui->kcfg_ocrLanguage->setEnabled(false); ++ m_ui->kcfg_ocrLanguage->addItem(i18n("OCR not available")); ++ m_ui->ocrLanguageLabel->setVisible(false); ++ m_ui->kcfg_ocrLanguage->setVisible(false); ++ m_ui->ocrUnavailableWidget->setVisible(true); ++ return; ++ } ++ ++ const auto availableLanguages = ocrManager->availableLanguagesWithNames(); ++ ++ if (availableLanguages.isEmpty()) { ++ m_ui->kcfg_ocrLanguage->addItem(i18n("No languages found")); ++ m_ui->kcfg_ocrLanguage->setEnabled(false); ++ return; ++ } ++ ++ m_ui->kcfg_ocrLanguage->clear(); ++ m_ui->ocrLanguageLabel->setVisible(true); ++ m_ui->kcfg_ocrLanguage->setVisible(true); ++ m_ui->ocrUnavailableWidget->setVisible(false); ++ ++ for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) { ++ m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key()); ++ } ++} ++ ++void GeneralOptionsPage::refreshOcrLanguageSettings() ++{ ++ OcrManager *ocrManager = OcrManager::instance(); ++ ++ if (!ocrManager->isAvailable()) { ++ m_ui->ocrLanguageLabel->setVisible(false); ++ m_ui->kcfg_ocrLanguage->setVisible(false); ++ m_ui->ocrUnavailableWidget->setVisible(true); ++ return; ++ } ++ ++ const auto availableLanguages = ocrManager->availableLanguagesWithNames(); ++ ++ if (availableLanguages.isEmpty()) { ++ return; ++ } ++ ++ m_ui->kcfg_ocrLanguage->clear(); ++ m_ui->kcfg_ocrLanguage->setEnabled(true); ++ m_ui->ocrLanguageLabel->setVisible(true); ++ m_ui->kcfg_ocrLanguage->setVisible(true); ++ m_ui->ocrUnavailableWidget->setVisible(false); ++ ++ for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) { ++ m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key()); ++ } ++ ++ const QString currentLanguage = Settings::ocrLanguage(); ++ ++ for (int i = 0; i < m_ui->kcfg_ocrLanguage->count(); ++i) { ++ if (m_ui->kcfg_ocrLanguage->itemData(i).toString() == currentLanguage) { ++ m_ui->kcfg_ocrLanguage->setCurrentIndex(i); ++ break; ++ } ++ } ++} ++ + #include "moc_GeneralOptionsPage.cpp" +diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.h b/src/Gui/SettingsDialog/GeneralOptionsPage.h +index d8e7c5003..c184d6ba8 100644 +--- a/src/Gui/SettingsDialog/GeneralOptionsPage.h ++++ b/src/Gui/SettingsDialog/GeneralOptionsPage.h +@@ -19,8 +19,12 @@ class GeneralOptionsPage : public QWidget + public: + explicit GeneralOptionsPage(QWidget *parent = nullptr); + ~GeneralOptionsPage() override; ++ ++ void refreshOcrLanguageSettings(); + + private: ++ void setupOcrLanguageComboBox(); ++ + QScopedPointer m_ui; + }; + +diff --git a/src/Gui/SettingsDialog/SettingsDialog.cpp b/src/Gui/SettingsDialog/SettingsDialog.cpp +index a37d8344c..a19a47627 100644 +--- a/src/Gui/SettingsDialog/SettingsDialog.cpp ++++ b/src/Gui/SettingsDialog/SettingsDialog.cpp +@@ -64,6 +64,9 @@ void SettingsDialog::showEvent(QShowEvent *event) + auto parent = parentWidget(); + bool onTop = parent && parent->windowHandle()->flags().testFlag(Qt::WindowStaysOnTopHint); + windowHandle()->setFlag(Qt::WindowStaysOnTopHint, onTop); ++ ++ m_generalPage->refreshOcrLanguageSettings(); ++ + KConfigDialog::showEvent(event); + } + +@@ -87,6 +90,8 @@ void SettingsDialog::updateWidgets() + { + KConfigDialog::updateWidgets(); + m_shortcutsPage->resetChanges(); ++ ++ m_generalPage->refreshOcrLanguageSettings(); + } + + void SettingsDialog::updateWidgetsDefault() +diff --git a/src/Gui/SettingsDialog/spectacle.kcfg b/src/Gui/SettingsDialog/spectacle.kcfg +index e37b9e5b4..4517e2344 100644 +--- a/src/Gui/SettingsDialog/spectacle.kcfg ++++ b/src/Gui/SettingsDialog/spectacle.kcfg +@@ -70,6 +70,10 @@ + + UntilClosed + ++ ++ ++ eng ++ + + + +diff --git a/src/Gui/ViewerPage.qml b/src/Gui/ViewerPage.qml +index 6e77887a8..602e4431b 100644 +--- a/src/Gui/ViewerPage.qml ++++ b/src/Gui/ViewerPage.qml +@@ -61,6 +61,11 @@ EmptyPage { + visible: action.enabled + action: CopyImageAction {} + } ++ TtToolButton { ++ display: TtToolButton.IconOnly ++ visible: action.enabled && SpectacleCore.ocrAvailable ++ action: OcrAction {} ++ } + // We only show this in video mode to save space in screenshot mode + TtToolButton { + visible: SpectacleCore.videoMode +diff --git a/src/Gui/ViewerWindow.cpp b/src/Gui/ViewerWindow.cpp +index 68812495d..8c0d9941f 100644 +--- a/src/Gui/ViewerWindow.cpp ++++ b/src/Gui/ViewerWindow.cpp +@@ -8,9 +8,9 @@ + #include "ViewerWindow.h" + + #include "Config.h" +-#include "SpectacleCore.h" + #include "Gui/ExportMenu.h" + #include "InlineMessageModel.h" ++#include "SpectacleCore.h" + + #include + #include +diff --git a/src/OcrManager.cpp b/src/OcrManager.cpp +new file mode 100644 +index 000000000..1d09db8ef +--- /dev/null ++++ b/src/OcrManager.cpp +@@ -0,0 +1,716 @@ ++/* This file is part of Spectacle, the KDE screenshot utility ++ * SPDX-FileCopyrightText: 2025 Jhair Paris ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#include "OcrManager.h" ++#include "settings.h" ++#include "spectacle_debug.h" ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include ++ ++#include ++ ++using namespace Qt::StringLiterals; ++ ++OcrManager *OcrManager::s_instance = nullptr; ++ ++OcrManager::OcrManager(QObject *parent) ++ : QObject(parent) ++#ifdef HAVE_TESSERACT_OCR ++ , m_tesseract(nullptr) ++ , m_worker(nullptr) ++#endif ++ , m_workerThread(std::make_unique()) ++ , m_timeoutTimer(new QTimer(this)) ++ , m_status(OcrStatus::Ready) ++ , m_currentLanguageCode() // Current language code ("eng+spa") ++ , m_configuredLanguages() // Languages from Settings (persistent) ++ , m_activeLanguages() ++ , m_shouldRestoreToConfigured(false) // Flag to restore after temp language use ++ , m_initialized(false) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ m_timeoutTimer->setSingleShot(true); ++ m_timeoutTimer->setInterval(30000); ++ ++ connect(m_timeoutTimer, &QTimer::timeout, this, [this]() { ++ qCWarning(SPECTACLE_LOG) << "OCR recognition timed out"; ++ setStatus(OcrStatus::Error); ++ }); ++ ++ m_worker = new OcrWorker(); ++ m_worker->moveToThread(m_workerThread.get()); ++ connect(m_worker, &OcrWorker::imageProcessed, this, &OcrManager::handleRecognitionComplete); ++ m_workerThread->start(); ++ ++ connect(Settings::self(), &Settings::ocrLanguagesChanged, this, [this]() { ++ const QStringList newLanguages = Settings::ocrLanguages(); ++ const QString combinedLanguages = newLanguages.join(u"+"_s); ++ if (combinedLanguages != m_currentLanguageCode) { ++ setLanguagesByCode(newLanguages); ++ } ++ }); ++ ++ QTimer::singleShot(0, this, &OcrManager::initializeTesseract); ++#endif ++} ++ ++OcrManager::~OcrManager() ++{ ++#ifdef HAVE_TESSERACT_OCR ++ if (m_worker) { ++ if (m_workerThread && m_workerThread->isRunning()) { ++ QMetaObject::invokeMethod(m_worker, &QObject::deleteLater, Qt::QueuedConnection); ++ } else { ++ delete m_worker; ++ } ++ m_worker = nullptr; ++ } ++#endif ++ if (m_workerThread && m_workerThread->isRunning()) { ++ m_workerThread->quit(); ++ m_workerThread->wait(3000); ++ } ++#ifdef HAVE_TESSERACT_OCR ++ if (m_tesseract) { ++ m_tesseract->End(); ++ delete m_tesseract; ++ m_tesseract = nullptr; ++ } ++#endif ++} ++ ++OcrManager *OcrManager::instance() ++{ ++ if (!s_instance) { ++ s_instance = new OcrManager(qApp); ++ } ++ return s_instance; ++} ++ ++bool OcrManager::isAvailable() const ++{ ++#ifdef HAVE_TESSERACT_OCR ++ return m_initialized && m_tesseract != nullptr; ++#else ++ return false; ++#endif ++} ++ ++OcrManager::OcrStatus OcrManager::status() const ++{ ++ return m_status; ++} ++ ++QMap OcrManager::availableLanguagesWithNames() const ++{ ++ QMap result; ++ for (const QString &langCode : m_availableLanguages) { ++ result[langCode] = m_languageNames.value(langCode, langCode); ++ } ++ return result; ++} ++ ++void OcrManager::setLanguagesByCode(const QStringList &languageCodes) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ if (languageCodes.isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "No OCR languages specified"; ++ return; ++ } ++ ++ if (validateAndApplyLanguages(languageCodes)) { ++ m_configuredLanguages = m_activeLanguages; ++ Settings::setOcrLanguages(m_activeLanguages); ++ Settings::self()->save(); ++ qCDebug(SPECTACLE_LOG) << "OCR languages successfully changed to:" << m_currentLanguageCode; ++ } else { ++ qCWarning(SPECTACLE_LOG) << "Failed to set OCR languages"; ++ } ++#else ++ Q_UNUSED(languageCodes); ++ qCWarning(SPECTACLE_LOG) << "OCR not available - Tesseract not compiled in"; ++#endif ++} ++ ++QString OcrManager::currentLanguageCode() const ++{ ++ return m_currentLanguageCode; ++} ++ ++void OcrManager::recognizeText(const QImage &image) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ if (!isAvailable()) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: engine is not available"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ ++ if (m_status == OcrStatus::Processing) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: text extraction already running"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ ++ if (image.isNull() || image.size().isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: invalid image provided"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ ++ // Ensure configured languages are active ++ if (m_configuredLanguages.isEmpty() || m_activeLanguages != m_configuredLanguages) { ++ if (!validateAndApplyLanguages(m_configuredLanguages)) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: failed to activate configured languages"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ } ++ ++ beginRecognition(image); ++#else ++ Q_UNUSED(image); ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: Spectacle built without Tesseract support"; ++ Q_EMIT textRecognized(QString(), false); ++#endif ++} ++ ++void OcrManager::recognizeTextWithLanguage(const QImage &image, const QString &languageCode) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ if (languageCode.isEmpty()) { ++ recognizeText(image); ++ return; ++ } ++ ++ if (!isAvailable()) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": engine is not available"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ ++ if (m_status == OcrStatus::Processing) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": text extraction already running"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ ++ if (image.isNull() || image.size().isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": invalid image provided"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ ++ const QStringList tempLanguages{languageCode}; ++ if (!validateAndApplyLanguages(tempLanguages)) { ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR with language" << languageCode << ": failed to activate language"; ++ Q_EMIT textRecognized(QString(), false); ++ return; ++ } ++ ++ // Store that we need to restore after recognition ++ m_shouldRestoreToConfigured = (m_activeLanguages != m_configuredLanguages); ++ ++ beginRecognition(image); ++#else ++ Q_UNUSED(image); ++ Q_UNUSED(languageCode); ++ qCWarning(SPECTACLE_LOG) << "Cannot start OCR: Spectacle built without Tesseract support"; ++ Q_EMIT textRecognized(QString(), false); ++#endif ++} ++ ++void OcrManager::handleRecognitionComplete(const QString &text, bool success) ++{ ++ m_timeoutTimer->stop(); ++ ++ if (success) { ++ setStatus(OcrStatus::Ready); ++ ++ if (!text.isEmpty()) { ++ QApplication::clipboard()->setText(text); ++ } ++ ++ Q_EMIT textRecognized(text, true); ++ qCDebug(SPECTACLE_LOG) << "OCR recognition completed successfully"; ++ } else { ++ setStatus(OcrStatus::Error); ++ Q_EMIT textRecognized(QString(), false); ++ qCWarning(SPECTACLE_LOG) << "OCR recognition failed"; ++ } ++ ++ // Restore configured languages if we used temporary ones ++ if (m_shouldRestoreToConfigured && !m_configuredLanguages.isEmpty()) { ++ validateAndApplyLanguages(m_configuredLanguages); ++ m_shouldRestoreToConfigured = false; ++ } ++} ++ ++bool OcrManager::validateAndApplyLanguages(const QStringList &languageCodes) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ if (languageCodes.isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "No OCR languages provided"; ++ return false; ++ } ++ ++ QStringList validLanguages; ++ for (const QString &lang : languageCodes) { ++ if (lang == u"osd"_s) { ++ qCDebug(SPECTACLE_LOG) << "Skipping 'osd' language"; ++ continue; ++ } ++ ++ if (!isLanguageAvailable(lang)) { ++ qCWarning(SPECTACLE_LOG) << "OCR language not available:" << lang; ++ continue; ++ } ++ ++ if (!validLanguages.contains(lang)) { ++ validLanguages.append(lang); ++ } ++ } ++ ++ if (validLanguages.isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "No valid OCR languages after filtering"; ++ return false; ++ } ++ ++ if (validLanguages.size() > MAX_OCR_LANGUAGES) { ++ validLanguages = validLanguages.mid(0, MAX_OCR_LANGUAGES); ++ qCInfo(SPECTACLE_LOG) << "Limited to" << MAX_OCR_LANGUAGES << "languages:" << validLanguages; ++ } ++ ++ const QString combinedLanguages = validLanguages.join(u"+"_s); ++ ++ if (m_currentLanguageCode == combinedLanguages && !m_activeLanguages.isEmpty()) { ++ qCDebug(SPECTACLE_LOG) << "Languages already active, no change needed"; ++ return true; ++ } ++ ++ if (!setupTesseractLanguages(validLanguages)) { ++ qCWarning(SPECTACLE_LOG) << "Failed to apply OCR languages:" << combinedLanguages; ++ return false; ++ } ++ ++ m_activeLanguages = validLanguages; ++ m_currentLanguageCode = combinedLanguages; ++ ++ qCDebug(SPECTACLE_LOG) << "OCR languages applied:" << combinedLanguages; ++ return true; ++#else ++ Q_UNUSED(languageCodes); ++ return false; ++#endif ++} ++ ++void OcrManager::beginRecognition(const QImage &image) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ setStatus(OcrStatus::Processing); ++ m_timeoutTimer->start(); ++ ++ QMetaObject::invokeMethod( ++ m_worker, ++ [worker = m_worker, image, tesseract = m_tesseract]() { ++ worker->processImage(image, tesseract); ++ }, ++ Qt::QueuedConnection); ++#else ++ Q_UNUSED(image); ++#endif ++} ++ ++void OcrManager::initializeTesseract() ++{ ++#ifdef HAVE_TESSERACT_OCR ++ try { ++ m_tesseract = new tesseract::TessBaseAPI(); ++ ++ if (m_tesseract->Init(nullptr, nullptr) != 0) { ++ qCWarning(SPECTACLE_LOG) << "Failed to initialize Tesseract OCR engine with auto-detection"; ++ setStatus(OcrStatus::Error); ++ delete m_tesseract; ++ m_tesseract = nullptr; ++ return; ++ } ++ ++ const char *datapath = m_tesseract->GetDatapath(); ++ QString tessdataPath = datapath ? QString::fromUtf8(datapath) : QString(); ++ qCDebug(SPECTACLE_LOG) << "Using tessdata path: " << tessdataPath; ++ ++ setupAvailableLanguages(tessdataPath); ++ ++ if (m_availableLanguages.isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "No language data files found in tessdata directory"; ++ setStatus(OcrStatus::Error); ++ delete m_tesseract; ++ m_tesseract = nullptr; ++ return; ++ } ++ ++ m_tesseract->End(); ++ ++ QStringList configLanguages = Settings::ocrLanguages(); ++ QStringList initLanguages; ++ ++ // Use configured languages if valid, otherwise fallback to first available ++ for (const QString &lang : configLanguages) { ++ if (!lang.isEmpty() && m_availableLanguages.contains(lang) && lang != u"osd"_s) { ++ initLanguages.append(lang); ++ } ++ } ++ ++ if (initLanguages.isEmpty()) { ++ auto it = std::find_if(m_availableLanguages.begin(), m_availableLanguages.end(), [](const QString &lang) { ++ return lang != u"osd"_s; ++ }); ++ ++ if (it != m_availableLanguages.end()) { ++ initLanguages.append(*it); ++ } else { ++ qCCritical(SPECTACLE_LOG) << "No fallback language available (only osd present)"; ++ setStatus(OcrStatus::Error); ++ delete m_tesseract; ++ m_tesseract = nullptr; ++ return; ++ } ++ } ++ ++ const QString combinedInitLanguages = initLanguages.join(u"+"_s); ++ qCDebug(SPECTACLE_LOG) << "Initializing Tesseract with languages:" << combinedInitLanguages; ++ ++ if (m_tesseract->Init(nullptr, combinedInitLanguages.toUtf8().constData()) != 0) { ++ qCWarning(SPECTACLE_LOG) << "Failed to initialize Tesseract with languages:" << combinedInitLanguages; ++ setStatus(OcrStatus::Error); ++ delete m_tesseract; ++ m_tesseract = nullptr; ++ return; ++ } ++ ++ m_currentLanguageCode = combinedInitLanguages; ++ m_tesseract->SetPageSegMode(tesseract::PSM_AUTO); ++ ++ m_initialized = true; ++ setStatus(OcrStatus::Ready); ++ qCDebug(SPECTACLE_LOG) << "Tesseract OCR engine initialized successfully with languages:" << combinedInitLanguages; ++ ++ loadSavedLanguageSetting(); ++ } catch (const std::exception &e) { ++ qCWarning(SPECTACLE_LOG) << "Exception during Tesseract initialization:" << e.what(); ++ setStatus(OcrStatus::Error); ++ if (m_tesseract) { ++ delete m_tesseract; ++ m_tesseract = nullptr; ++ } ++ } ++#else ++ qCDebug(SPECTACLE_LOG) << "Tesseract OCR not available - compiled out"; ++ setStatus(OcrStatus::Error); ++#endif ++} ++ ++void OcrManager::loadSavedLanguageSetting() ++{ ++ if (!isAvailable()) { ++ qCDebug(SPECTACLE_LOG) << "OCR not available, skipping language loading"; ++ return; ++ } ++ ++ QStringList savedLanguages = Settings::ocrLanguages(); ++ qCDebug(SPECTACLE_LOG) << "Loaded OCR languages setting from config:" << savedLanguages; ++ qCDebug(SPECTACLE_LOG) << "Current OCR language code:" << m_currentLanguageCode; ++ qCDebug(SPECTACLE_LOG) << "Available languages:" << m_availableLanguages; ++ ++ QStringList validLanguages; ++ for (const QString &lang : savedLanguages) { ++ if (lang != u"osd"_s && isLanguageAvailable(lang)) { ++ validLanguages.append(lang); ++ } ++ } ++ ++ if (validLanguages.isEmpty()) { ++ // Find first valid language as fallback ++ auto it = std::find_if(m_availableLanguages.begin(), m_availableLanguages.end(), [](const QString &lang) { ++ return lang != u"osd"_s; ++ }); ++ if (it != m_availableLanguages.end()) { ++ validLanguages.append(*it); ++ } else { ++ qCWarning(SPECTACLE_LOG) << "No usable languages available (only osd present), cannot set default"; ++ return; ++ } ++ qCDebug(SPECTACLE_LOG) << "No valid saved languages, using default:" << validLanguages; ++ Settings::setOcrLanguages(validLanguages); ++ Settings::self()->save(); ++ } ++ ++ m_configuredLanguages = validLanguages; ++ ++ const QString combinedLanguages = validLanguages.join(u"+"_s); ++ if (combinedLanguages != m_currentLanguageCode) { ++ qCDebug(SPECTACLE_LOG) << "Loading OCR languages setting:" << validLanguages; ++ validateAndApplyLanguages(validLanguages); ++ } else { ++ qCDebug(SPECTACLE_LOG) << "OCR languages already set to:" << combinedLanguages; ++ m_activeLanguages = validLanguages; ++ } ++} ++ ++void OcrManager::setStatus(OcrStatus status) ++{ ++ if (m_status == status) { ++ return; ++ } ++ ++ m_status = status; ++ Q_EMIT statusChanged(status); ++} ++ ++bool OcrManager::isLanguageAvailable(const QString &languageCode) const ++{ ++ return m_availableLanguages.contains(languageCode); ++} ++ ++bool OcrManager::setupTesseractLanguages(const QStringList &langCodes) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ if (!m_tesseract || langCodes.isEmpty()) { ++ return false; ++ } ++ ++ const char *datapath = m_tesseract->GetDatapath(); ++ QString tessdataPath = datapath ? QString::fromUtf8(datapath) : QString(); ++ ++ if (tessdataPath.isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "Tessdata path not found"; ++ return false; ++ } ++ ++ for (const QString &langCode : langCodes) { ++ const QString langFile = QDir(tessdataPath).filePath(langCode + u".traineddata"_s); ++ if (!QFile::exists(langFile)) { ++ qCWarning(SPECTACLE_LOG) << "Language file not found:" << langFile; ++ return false; ++ } ++ } ++ ++ try { ++ m_tesseract->End(); ++ ++ const QString combinedLangs = langCodes.join(u"+"_s); ++ ++ if (m_tesseract->Init(nullptr, combinedLangs.toUtf8().constData()) != 0) { ++ qCWarning(SPECTACLE_LOG) << "Failed to initialize Tesseract with languages:" << combinedLangs; ++ ++ // Fallback to first available language ++ QString fallbackLang; ++ if (!m_availableLanguages.isEmpty()) { ++ auto it = std::find_if(m_availableLanguages.begin(), m_availableLanguages.end(), [](const QString &lang) { ++ return lang != u"osd"_s; ++ }); ++ if (it != m_availableLanguages.end()) { ++ fallbackLang = *it; ++ } ++ } ++ ++ if (!fallbackLang.isEmpty() && m_tesseract->Init(nullptr, fallbackLang.toUtf8().constData()) != 0) { ++ qCCritical(SPECTACLE_LOG) << "Failed to fallback to language:" << fallbackLang; ++ return false; ++ } ++ return false; ++ } ++ ++ m_tesseract->SetPageSegMode(tesseract::PSM_AUTO); ++ return true; ++ } catch (const std::exception &e) { ++ qCWarning(SPECTACLE_LOG) << "Exception while setting up Tesseract languages:" << e.what(); ++ return false; ++ } ++#else ++ Q_UNUSED(langCodes); ++ return false; ++#endif ++} ++ ++void OcrManager::setupAvailableLanguages(const QString &tessdataPath) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ m_availableLanguages.clear(); ++ m_languageNames.clear(); ++ ++ if (!m_tesseract) { ++ qCWarning(SPECTACLE_LOG) << "Cannot enumerate OCR languages: Tesseract not initialized"; ++ return; ++ } ++ ++ QStringList detectedLanguages; ++ ++ try { ++ std::vector available; ++ m_tesseract->GetAvailableLanguagesAsVector(&available); ++ detectedLanguages.reserve(static_cast(available.size())); ++ ++ for (const std::string &language : available) { ++ const QString langCode = QString::fromStdString(language); ++ if (langCode.isEmpty()) { ++ continue; ++ } ++ ++ if (!tessdataPath.isEmpty()) { ++ const QString trainedDataPath = QDir(tessdataPath).filePath(langCode + u".traineddata"_s); ++ if (!QFile::exists(trainedDataPath)) { ++ qCDebug(SPECTACLE_LOG) << "Skipping OCR language" << langCode << "- missing traineddata at" << trainedDataPath; ++ continue; ++ } ++ } ++ ++ if (!detectedLanguages.contains(langCode)) { ++ detectedLanguages.append(langCode); ++ } ++ } ++ } catch (const std::exception &e) { ++ qCWarning(SPECTACLE_LOG) << "Exception while enumerating Tesseract languages:" << e.what(); ++ } ++ ++ std::sort(detectedLanguages.begin(), detectedLanguages.end()); ++ m_availableLanguages = detectedLanguages; ++ ++ for (const QString &langCode : std::as_const(m_availableLanguages)) { ++ if (langCode == u"osd"_s) { ++ m_languageNames.insert(langCode, i18nc("@item:inlistbox", "Orientation and Script Detection")); ++ continue; ++ } ++ ++ const QString displayName = tesseractLangName(langCode); ++ m_languageNames.insert(langCode, displayName); ++ } ++ ++ qCDebug(SPECTACLE_LOG) << "Detected OCR languages:" << m_availableLanguages; ++#else ++ Q_UNUSED(tessdataPath); ++#endif ++} ++ ++QString OcrManager::tesseractLangName(const QString &tesseractCode) const ++{ ++ static const QMap tesseractToIsoMap = { ++ {u"afr"_s, u"af"_s}, {u"ara"_s, u"ar"_s}, {u"aze"_s, u"az"_s}, {u"aze_cyrl"_s, u"az"_s}, {u"bel"_s, u"be"_s}, ++ {u"ben"_s, u"bn"_s}, {u"bul"_s, u"bg"_s}, {u"cat"_s, u"ca"_s}, {u"ces"_s, u"cs"_s}, {u"chi_sim"_s, u"zh_CN"_s}, ++ {u"chi_tra"_s, u"zh_TW"_s}, {u"cym"_s, u"cy"_s}, {u"dan"_s, u"da"_s}, {u"dan_frak"_s, u"da"_s}, {u"deu"_s, u"de"_s}, ++ {u"deu_frak"_s, u"de"_s}, {u"deu_latf"_s, u"de"_s}, {u"ell"_s, u"el"_s}, {u"eng"_s, u"en"_s}, {u"epo"_s, u"eo"_s}, ++ {u"est"_s, u"et"_s}, {u"eus"_s, u"eu"_s}, {u"fas"_s, u"fa"_s}, {u"fin"_s, u"fi"_s}, {u"fra"_s, u"fr"_s}, ++ {u"frk"_s, u"de"_s}, {u"gla"_s, u"gd"_s}, {u"gle"_s, u"ga"_s}, {u"glg"_s, u"gl"_s}, {u"heb"_s, u"he"_s}, ++ {u"hin"_s, u"hi"_s}, {u"hrv"_s, u"hr"_s}, {u"hun"_s, u"hu"_s}, {u"ind"_s, u"id"_s}, {u"isl"_s, u"is"_s}, ++ {u"ita"_s, u"it"_s}, {u"ita_old"_s, u"it"_s}, {u"jpn"_s, u"ja"_s}, {u"kor"_s, u"ko"_s}, {u"kor_vert"_s, u"ko"_s}, ++ {u"lav"_s, u"lv"_s}, {u"lit"_s, u"lt"_s}, {u"nld"_s, u"nl"_s}, {u"nor"_s, u"no"_s}, {u"pol"_s, u"pl"_s}, ++ {u"por"_s, u"pt"_s}, {u"ron"_s, u"ro"_s}, {u"rus"_s, u"ru"_s}, {u"slk"_s, u"sk"_s}, {u"slk_frak"_s, u"sk"_s}, ++ {u"slv"_s, u"sl"_s}, {u"spa"_s, u"es"_s}, {u"spa_old"_s, u"es"_s}, {u"srp"_s, u"sr"_s}, {u"srp_latn"_s, u"sr"_s}, ++ {u"swe"_s, u"sv"_s}, {u"tur"_s, u"tr"_s}, {u"ukr"_s, u"uk"_s}, {u"vie"_s, u"vi"_s}, {u"amh"_s, u"am"_s}, ++ {u"asm"_s, u"as"_s}, {u"bod"_s, u"bo"_s}, {u"dzo"_s, u"dz"_s}, {u"guj"_s, u"gu"_s}, {u"kan"_s, u"kn"_s}, ++ {u"kat"_s, u"ka"_s}, {u"kat_old"_s, u"ka"_s}, {u"kaz"_s, u"kk"_s}, {u"khm"_s, u"km"_s}, {u"kir"_s, u"ky"_s}, ++ {u"lao"_s, u"lo"_s}, {u"mal"_s, u"ml"_s}, {u"mar"_s, u"mr"_s}, {u"mya"_s, u"my"_s}, {u"nep"_s, u"ne"_s}, ++ {u"ori"_s, u"or"_s}, {u"pan"_s, u"pa"_s}, {u"sin"_s, u"si"_s}, {u"tam"_s, u"ta"_s}, {u"tel"_s, u"te"_s}, ++ {u"tha"_s, u"th"_s}, {u"urd"_s, u"ur"_s}, {u"bos"_s, u"bs"_s}, {u"bre"_s, u"br"_s}, {u"cos"_s, u"co"_s}, ++ {u"fao"_s, u"fo"_s}, {u"fil"_s, u"tl"_s}, {u"fry"_s, u"fy"_s}, {u"hat"_s, u"ht"_s}, {u"hye"_s, u"hy"_s}, ++ {u"iku"_s, u"iu"_s}, {u"jav"_s, u"jv"_s}, {u"kmr"_s, u"ku"_s}, {u"kur"_s, u"ku"_s}, {u"lat"_s, u"la"_s}, ++ {u"ltz"_s, u"lb"_s}, {u"mkd"_s, u"mk"_s}, {u"mlt"_s, u"mt"_s}, {u"mon"_s, u"mn"_s}, {u"mri"_s, u"mi"_s}, ++ {u"msa"_s, u"ms"_s}, {u"oci"_s, u"oc"_s}, {u"pus"_s, u"ps"_s}, {u"que"_s, u"qu"_s}, {u"san"_s, u"sa"_s}, ++ {u"snd"_s, u"sd"_s}, {u"sqi"_s, u"sq"_s}, {u"sun"_s, u"su"_s}, {u"swa"_s, u"sw"_s}, {u"tat"_s, u"tt"_s}, ++ {u"tgk"_s, u"tg"_s}, {u"tgl"_s, u"tl"_s}, {u"tir"_s, u"ti"_s}, {u"ton"_s, u"to"_s}, {u"uig"_s, u"ug"_s}, ++ {u"uzb"_s, u"uz"_s}, {u"uzb_cyrl"_s, u"uz"_s}, {u"yid"_s, u"yi"_s}, {u"yor"_s, u"yo"_s}, ++ }; ++ ++ if (tesseractCode == u"equ"_s) { ++ return i18n("Math/Equation Detection"); ++ } ++ if (tesseractCode == u"osd"_s) { ++ return i18n("Orientation and Script Detection"); ++ } ++ ++ const QString isoCode = tesseractToIsoMap.value(tesseractCode); ++ if (!isoCode.isEmpty()) { ++ QLocale locale(isoCode); ++ QString name = locale.nativeLanguageName(); ++ ++ if (!name.isEmpty()) { ++ name[0] = name[0].toUpper(); ++ return name; ++ } ++ ++ QString languageName = QLocale::languageToString(locale.language()); ++ if (!languageName.isEmpty()) { ++ languageName[0] = languageName[0].toUpper(); ++ return languageName; ++ } ++ } ++ ++ return tesseractCode; ++} ++ ++OcrWorker::OcrWorker(QObject *parent) ++ : QObject(parent) ++{ ++} ++ ++void OcrWorker::processImage(const QImage &image, tesseract::TessBaseAPI *tesseract) ++{ ++#ifdef HAVE_TESSERACT_OCR ++ QMutexLocker locker(&m_mutex); ++ ++ if (!tesseract || image.isNull()) { ++ Q_EMIT imageProcessed(QString(), false); ++ return; ++ } ++ ++ try { ++ QImage rgbImage = image.convertToFormat(QImage::Format_RGB888); ++ ++ tesseract->SetImage(rgbImage.bits(), rgbImage.width(), rgbImage.height(), 3, rgbImage.bytesPerLine()); ++ ++ if (tesseract->Recognize(0) != 0) { ++ Q_EMIT imageProcessed(QString(), false); ++ return; ++ } ++ ++ QStringList lines; ++ std::unique_ptr iterator(tesseract->GetIterator()); ++ ++ if (iterator) { ++ do { ++ const char *lineText = iterator->GetUTF8Text(tesseract::RIL_TEXTLINE); ++ if (lineText != nullptr) { ++ QString line = QString::fromUtf8(lineText).trimmed(); ++ if (!line.isEmpty()) { ++ lines.append(line); ++ } ++ delete[] lineText; ++ } ++ } while (iterator->Next(tesseract::RIL_TEXTLINE)); ++ } ++ ++ const QString result = lines.join(QLatin1Char('\n')).trimmed(); ++ Q_EMIT imageProcessed(result, true); ++ } catch (const std::exception &e) { ++ qCWarning(SPECTACLE_LOG) << "Exception in OCR worker:" << e.what(); ++ Q_EMIT imageProcessed(QString(), false); ++ } ++#else ++ Q_UNUSED(image); ++ Q_UNUSED(tesseract); ++ Q_EMIT imageProcessed(QString(), false); ++#endif ++} +diff --git a/src/OcrManager.h b/src/OcrManager.h +new file mode 100644 +index 000000000..c71505b3e +--- /dev/null ++++ b/src/OcrManager.h +@@ -0,0 +1,175 @@ ++/* This file is part of Spectacle, the KDE screenshot utility ++ * SPDX-FileCopyrightText: 2025 Jhair Paris ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#pragma once ++ ++#include "Config.h" ++ ++#ifdef HAVE_TESSERACT_OCR ++#include ++#else ++namespace tesseract ++{ ++class TessBaseAPI; ++} ++#endif ++ ++#include ++#include ++#include ++#include ++#include ++#include ++#include ++ ++#include ++ ++/** ++ * @brief Worker class for OCR processing in background thread ++ */ ++class OcrWorker : public QObject ++{ ++ Q_OBJECT ++ ++public: ++ explicit OcrWorker(QObject *parent = nullptr); ++ ++public Q_SLOTS: ++ void processImage(const QImage &image, tesseract::TessBaseAPI *tesseract); ++ ++Q_SIGNALS: ++ void imageProcessed(const QString &text, bool success); ++ ++private: ++ QMutex m_mutex; ++}; ++ ++/** ++ * This class uses Tesseract OCR engine to extract text from images. ++ * It provides both synchronous and asynchronous text recognition capabilities. ++ */ ++class OcrManager : public QObject ++{ ++ Q_OBJECT ++ ++public: ++ static constexpr int MAX_OCR_LANGUAGES = 4; ++ static constexpr int MIN_OCR_LANGUAGES = 1; ++ enum class OcrStatus { ++ Ready = 0, ++ Processing = 1, ++ Error = 2 ++ }; ++ Q_ENUM(OcrStatus) ++ ++ explicit OcrManager(QObject *parent = nullptr); ++ ~OcrManager() override; ++ ++ static OcrManager *instance(); ++ ++ /** ++ * @brief Check if OCR engine is available and properly initialized ++ * @return true if OCR is available, false otherwise ++ */ ++ bool isAvailable() const; ++ ++ /** ++ * @brief Get the current OCR processing status ++ * @return Current status of the OCR engine ++ */ ++ OcrStatus status() const; ++ ++ /** ++ * @brief Get a map of available languages with human-readable names ++ * @return QMap where key is language code and value is display name ++ */ ++ QMap availableLanguagesWithNames() const; ++ ++ /** ++ * @brief Set multiple languages for OCR processing ++ * @param languageCodes List of language codes to use (e.g., ["eng", "spa", "fra"]) ++ */ ++ void setLanguagesByCode(const QStringList &languageCodes); ++ ++ /** ++ * @brief Get the current language code ++ * @return Current language code (e.g., "eng", "spa") ++ */ ++ QString currentLanguageCode() const; ++ ++public Q_SLOTS: ++ /** ++ * @brief Extract text from an image asynchronously ++ * @param image The image to process ++ * ++ * This method processes the image in a background thread and emits ++ * textRecognized() signal when complete. ++ */ ++ void recognizeText(const QImage &image); ++ ++ /** ++ * @brief Extract text from an image using a temporary language selection ++ * @param image The image to process ++ * @param languageCode The one-off language code to use (e.g. "eng") ++ * ++ * The provided language is applied only for this recognition request and ++ * does not persist the user's saved configuration. ++ */ ++ void recognizeTextWithLanguage(const QImage &image, const QString &languageCode); ++ ++Q_SIGNALS: ++ /** ++ * @brief Emitted when text recognition is complete ++ * @param text The recognized text ++ * @param success true if recognition was successful ++ */ ++ void textRecognized(const QString &text, bool success); ++ ++ /** ++ * @brief Emitted when OCR status changes ++ * @param status New status ++ */ ++ void statusChanged(OcrStatus status); ++ ++private Q_SLOTS: ++ void handleRecognitionComplete(const QString &text, bool success); ++ ++private: ++ void initializeTesseract(); ++ void setStatus(OcrStatus status); ++ bool setupTesseractLanguages(const QStringList &langCodes); ++ void setupAvailableLanguages(const QString &tessdataPath); ++ void loadSavedLanguageSetting(); ++ bool isLanguageAvailable(const QString &languageCode) const; ++ QString tesseractLangName(const QString &tesseractCode) const; ++ ++ /** ++ * @brief Validate, filter, and apply languages to Tesseract ++ * @param languageCodes Languages to validate and apply ++ * @return true if languages were successfully applied ++ */ ++ bool validateAndApplyLanguages(const QStringList &languageCodes); ++ void beginRecognition(const QImage &image); ++ ++ static OcrManager *s_instance; ++ ++#ifdef HAVE_TESSERACT_OCR ++ tesseract::TessBaseAPI *m_tesseract; ++ OcrWorker *m_worker; ++#endif ++ std::unique_ptr m_workerThread; ++ QTimer *m_timeoutTimer; ++ ++ OcrStatus m_status; ++ QString m_currentLanguageCode; ++ QStringList m_configuredLanguages; ++ QStringList m_activeLanguages; ++ bool m_shouldRestoreToConfigured; ++ QStringList m_availableLanguages; ++ QMap m_languageNames; ++ bool m_initialized; ++ ++private: ++}; +\ No newline at end of file +diff --git a/src/SpectacleCore.cpp b/src/SpectacleCore.cpp +index 7371ce768..caada874e 100644 +--- a/src/SpectacleCore.cpp ++++ b/src/SpectacleCore.cpp +@@ -1,6 +1,7 @@ + /* + * SPDX-FileCopyrightText: 2019 David Redondo + * SPDX-FileCopyrightText: 2015 Boudhayan Gupta ++ * SPDX-FileCopyrightText: 2025 Jhair Paris + * + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +@@ -20,6 +21,7 @@ + #include "Gui/HelpMenu.h" + #include "Gui/OptionsMenu.h" + #include "Gui/InlineMessageModel.h" ++#include "OcrManager.h" + #include "Platforms/ImagePlatformXcb.h" + #include "Platforms/VideoPlatform.h" + #include "ShortcutActions.h" +@@ -49,6 +51,7 @@ + #include + #include + #include ++#include + #include + #include + #include +@@ -59,6 +62,8 @@ + #include + #include + #include ++#include ++#include + #include + #include + #include +@@ -538,6 +543,63 @@ SpectacleCore::SpectacleCore(QObject *parent) + InlineMessageModel::instance()->push(InlineMessageModel::Scanned, text, result); + }; + connect(exportManager, &ExportManager::qrCodeScanned, this, onQRCodeScanned); ++ ++ auto onOcrTextRecognized = [this](const QString &text, bool success) { ++ if (!success) { ++ InlineMessageModel::instance()->push(InlineMessageModel::Error, ++ i18nc("@info", "Text extraction failed")); ++ return; ++ } ++ ++ if (text.isEmpty()) { ++ InlineMessageModel::instance()->push(InlineMessageModel::Copied, ++ i18nc("@info", "No text found in the image")); ++ return; ++ } ++ ++ InlineMessageModel::instance()->push(InlineMessageModel::Copied, ++ i18nc("@info", "Text extraction completed")); ++ ++ auto notification = new KNotification(u"ocrTextExtracted"_s, KNotification::CloseOnTimeout, this); ++ notification->setTitle(i18nc("@info:notification title", "Text Extracted")); ++ ++ notification->setText(i18nc("@info:notification", "Text copied to clipboard")); ++ notification->setIconName(u"document-scan"_s); ++ ++ if (!text.isEmpty()) { ++ auto openEditorAction = notification->addAction(i18nc("@action:button", "Open in Text Editor")); ++ connect(openEditorAction, &KNotificationAction::activated, this, [text]() { ++ // Create temporary file with extracted text ++ auto exportManager = ExportManager::instance(); ++ exportManager->updateTimestamp(); ++ auto timestamp = exportManager->timestamp(); ++ ++ QString filename = QStringLiteral("spectacle_ocr_%1.txt").arg(timestamp.toString(QStringLiteral("yyyyMMdd_HHmmss"))); ++ QString templatePath = QDir::tempPath() + QStringLiteral("/") + filename; ++ ++ QTemporaryFile tempFile; ++ tempFile.setFileTemplate(templatePath); ++ tempFile.setAutoRemove(false); ++ ++ if (tempFile.open()) { ++ QTextStream stream(&tempFile); ++ stream << text; ++ tempFile.close(); ++ ++ auto job = new KIO::OpenUrlJob(QUrl::fromLocalFile(tempFile.fileName())); ++ job->start(); ++ } ++ }); ++ } ++ ++ notification->sendEvent(); ++ }; ++ ++ // Connect to OCR manager ++ connect(OcrManager::instance(), &OcrManager::textRecognized, this, onOcrTextRecognized); ++ connect(OcrManager::instance(), &OcrManager::statusChanged, this, [this](OcrManager::OcrStatus) { ++ Q_EMIT ocrStatusChanged(); ++ }); + + connect(exportManager, &ExportManager::errorMessage, this, &SpectacleCore::showErrorMessage); + +@@ -582,6 +644,87 @@ SpectacleCore::SpectacleCore(QObject *parent) + }); + } + ++bool SpectacleCore::ocrAvailable() const ++{ ++ return OcrManager::instance()->isAvailable(); ++} ++ ++OcrManager::OcrStatus SpectacleCore::ocrStatus() const ++{ ++ return OcrManager::instance()->status(); ++} ++ ++QVariantMap SpectacleCore::ocrAvailableLanguages() const ++{ ++ auto ocrManager = OcrManager::instance(); ++ if (!ocrManager->isAvailable()) { ++ return QVariantMap(); ++ } ++ ++ auto languageMap = ocrManager->availableLanguagesWithNames(); ++ QVariantMap result; ++ for (auto it = languageMap.constBegin(); it != languageMap.constEnd(); ++it) { ++ result[it.key()] = it.value(); ++ } ++ return result; ++} ++ ++bool SpectacleCore::startOcrExtraction(const QString &languageCode) ++{ ++ if (m_videoMode) { ++ return false; ++ } ++ ++ const bool hasCaptureWindows = !CaptureWindow::instances().isEmpty(); ++ ++ if (hasCaptureWindows) { ++ auto selectionEditor = SelectionEditor::instance(); ++ auto inlineMessages = InlineMessageModel::instance(); ++ ++ if (!selectionEditor->acceptSelection(ExportManager::UserAction)) { ++ inlineMessages->push(InlineMessageModel::Error, i18nc("@info", "Please select a region before extracting text")); ++ return false; ++ } ++ ++ QMetaObject::invokeMethod( ++ this, ++ [this, languageCode]() { ++ performOcrExtraction(languageCode); ++ }, ++ Qt::QueuedConnection); ++ return true; ++ } ++ ++ return performOcrExtraction(languageCode); ++} ++ ++bool SpectacleCore::performOcrExtraction(const QString &languageCode) ++{ ++ auto ocrManager = OcrManager::instance(); ++ auto inlineMessages = InlineMessageModel::instance(); ++ ++ if (!ocrManager->isAvailable()) { ++ inlineMessages->push(InlineMessageModel::Error, i18nc("@info", "OCR is not available.")); ++ return false; ++ } ++ ++ const QImage image = m_annotationDocument->renderToImage(); ++ if (image.isNull()) { ++ inlineMessages->push(InlineMessageModel::Error, i18nc("@info", "No screenshot available.")); ++ return false; ++ } ++ ++ inlineMessages->push(InlineMessageModel::Copied, i18nc("@info", "Extracting text from image...")); ++ ++ if (languageCode.isEmpty()) { ++ ocrManager->recognizeText(image); ++ } else { ++ ocrManager->recognizeTextWithLanguage(image, languageCode); ++ } ++ ++ return true; ++} ++ + SpectacleCore::~SpectacleCore() noexcept + { + s_self = nullptr; +diff --git a/src/SpectacleCore.h b/src/SpectacleCore.h +index 23d65ead9..2c87ff8f4 100644 +--- a/src/SpectacleCore.h ++++ b/src/SpectacleCore.h +@@ -17,6 +17,7 @@ + #include "Gui/Annotations/AnnotationDocument.h" + #include "Gui/CaptureWindow.h" + #include "Gui/ViewerWindow.h" ++#include "OcrManager.h" + #include "Platforms/PlatformLoader.h" + #include "RecordingModeModel.h" + #include "VideoFormatModel.h" +@@ -40,6 +41,8 @@ class SpectacleCore : public QObject + Q_PROPERTY(bool videoMode READ videoMode WRITE setVideoMode NOTIFY videoModeChanged) + Q_PROPERTY(QUrl currentVideo READ currentVideo NOTIFY currentVideoChanged) + Q_PROPERTY(AnnotationDocument *annotationDocument READ annotationDocument CONSTANT FINAL) ++ Q_PROPERTY(bool ocrAvailable READ ocrAvailable NOTIFY ocrStatusChanged FINAL) ++ Q_PROPERTY(OcrManager::OcrStatus ocrStatus READ ocrStatus NOTIFY ocrStatusChanged FINAL) + + public: + enum class StartMode { +@@ -74,6 +77,10 @@ public: + + QUrl currentVideo() const; + ++ bool ocrAvailable() const; ++ OcrManager::OcrStatus ocrStatus() const; ++ Q_INVOKABLE QVariantMap ocrAvailableLanguages() const; ++ Q_INVOKABLE bool startOcrExtraction(const QString &languageCode = QString()); + + void initGuiNoScreenshot(); + +@@ -125,6 +132,7 @@ Q_SIGNALS: + void videoModeChanged(bool videoMode); + void currentVideoChanged(const QUrl ¤tVideo); + void recordedTimeChanged(); ++ void ocrStatusChanged(); + + private: + explicit SpectacleCore(QObject *parent = nullptr); +@@ -148,6 +156,7 @@ private: + void unityLauncherUpdate(const QVariantMap &properties) const; + void setCurrentVideo(const QUrl ¤tVideo); + QUrl videoOutputUrl() const; ++ bool performOcrExtraction(const QString &languageCode); + + static SpectacleCore *s_self; + std::unique_ptr m_annotationDocument = nullptr; +-- +GitLab + + +From a1f7ac0b716ea295cfec120bf8691dd86e56413b Mon Sep 17 00:00:00 2001 +From: Jhair Paris +Date: Mon, 13 Oct 2025 22:58:17 -0500 +Subject: [PATCH 3/3] add support for multiple OCR languages in preferences + dialog + +- Switch from single ocrLanguage string to ocrLanguages string list in settings +- Add OcrLanguageSelector widget for multi-language selection +- Integrate new selector into GeneralOptionsPage and SettingsDialog + +add OCR language menu to main interface + +- Introduce OcrLanguageMenu and OcrLanguageMenuButton components +- Expose language selection in ViewerPage and CaptureOverlay +- Move OCR extraction logic to SpectacleCore::startOcrExtraction + +Remove OCR language menu components and references from the project + +Add OCR language submenu to ExportMenu +--- + src/CMakeLists.txt | 1 + + src/Gui/CaptureOverlay.qml | 8 +- + src/Gui/ExportMenu.cpp | 84 ++++++ + src/Gui/ExportMenu.h | 5 + + src/Gui/OcrAction.qml | 7 +- + src/Gui/SettingsDialog/GeneralOptions.ui | 38 ++- + src/Gui/SettingsDialog/GeneralOptionsPage.cpp | 81 ++---- + src/Gui/SettingsDialog/GeneralOptionsPage.h | 16 +- + .../SettingsDialog/OcrLanguageSelector.cpp | 271 ++++++++++++++++++ + src/Gui/SettingsDialog/OcrLanguageSelector.h | 111 +++++++ + src/Gui/SettingsDialog/SettingsDialog.cpp | 17 +- + src/Gui/SettingsDialog/spectacle.kcfg | 4 +- + src/Gui/ViewerPage.qml | 4 +- + 13 files changed, 561 insertions(+), 86 deletions(-) + create mode 100644 src/Gui/SettingsDialog/OcrLanguageSelector.cpp + create mode 100644 src/Gui/SettingsDialog/OcrLanguageSelector.h + +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index c57535e34..6efeff637 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -44,6 +44,7 @@ target_sources(spectacle PRIVATE + Gui/SelectionEditor.cpp + Gui/SettingsDialog/GeneralOptionsPage.cpp + Gui/SettingsDialog/ImageSaveOptionsPage.cpp ++ Gui/SettingsDialog/OcrLanguageSelector.cpp + Gui/SettingsDialog/SettingsDialog.cpp + Gui/SettingsDialog/ShortcutsOptionsPage.cpp + Gui/SettingsDialog/VideoFormatComboBox.cpp +diff --git a/src/Gui/CaptureOverlay.qml b/src/Gui/CaptureOverlay.qml +index 37f3dcf85..431d76479 100644 +--- a/src/Gui/CaptureOverlay.qml ++++ b/src/Gui/CaptureOverlay.qml +@@ -506,11 +506,13 @@ MouseArea { + visible: action.enabled + action: CopyImageAction {} + } ++ + ToolButton { + display: TtToolButton.IconOnly +- visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable ++ visible: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable + action: OcrAction {} + } ++ + ExportMenuButton { + focusPolicy: Qt.NoFocus + } +@@ -537,10 +539,12 @@ MouseArea { + visible: action.enabled + action: CopyImageAction {} + } ++ + ToolButton { +- visible: action.enabled && !SpectacleCore.videoMode && SpectacleCore.ocrAvailable ++ visible: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable + action: OcrAction {} + } ++ + ExportMenuButton { + focusPolicy: Qt.NoFocus + } +diff --git a/src/Gui/ExportMenu.cpp b/src/Gui/ExportMenu.cpp +index e2c7dd5b6..57e1480fa 100644 +--- a/src/Gui/ExportMenu.cpp ++++ b/src/Gui/ExportMenu.cpp +@@ -6,6 +6,7 @@ + + #include "ExportMenu.h" + #include "CaptureWindow.h" ++#include "OcrManager.h" + #include "SpectacleCore.h" + #include "WidgetWindowUtils.h" + #include "settings.h" +@@ -54,6 +55,8 @@ ExportMenu::ExportMenu(QWidget *parent) + this, &ExportMenu::openScreenshotsFolder); + addAction(KStandardActions::print(this, &ExportMenu::openPrintDialog, this)); + ++ createOcrLanguageSubmenu(); ++ + #ifdef PURPOSE_FOUND + loadPurposeMenu(); + connect(ExportManager::instance(), &ExportManager::imageChanged, this, &ExportMenu::onImageChanged); +@@ -233,4 +236,85 @@ void ExportMenu::openPrintDialog() + dialog->setVisible(true); + } + ++void ExportMenu::createOcrLanguageSubmenu() ++{ ++ Q_ASSERT(!m_ocrLanguageMenu); ++ ++ auto ocrManager = OcrManager::instance(); ++ ++ if (!ocrManager || !ocrManager->isAvailable()) { ++ return; ++ } ++ ++ m_ocrLanguageMenu = addMenu(i18nc("@action:menu", "Extract Text by Language")); ++ m_ocrLanguageMenu->setIcon(QIcon::fromTheme(u"document-scan"_s)); ++ ++ // Keep the submenu in sync with OCR status changes ++ if (ocrManager) { ++ connect(ocrManager, &OcrManager::statusChanged, this, &ExportMenu::buildOcrLanguageSubmenu); ++ } ++ ++ if (auto settings = Settings::self()) { ++ connect(settings, &Settings::ocrLanguagesChanged, this, &ExportMenu::buildOcrLanguageSubmenu); ++ } ++ ++ connect(m_ocrLanguageMenu, &QMenu::aboutToShow, this, &ExportMenu::buildOcrLanguageSubmenu); ++ ++ buildOcrLanguageSubmenu(); ++} ++ ++void ExportMenu::buildOcrLanguageSubmenu() ++{ ++ if (!m_ocrLanguageMenu) { ++ return; ++ } ++ ++ m_ocrLanguageMenu->clear(); ++ ++ auto ocrManager = OcrManager::instance(); ++ ++ if (!ocrManager) { ++ QAction *action = m_ocrLanguageMenu->addAction(i18n("OCR engine is not available.")); ++ action->setEnabled(false); ++ return; ++ } ++ ++ const bool initializationFailed = ocrManager->status() == OcrManager::OcrStatus::Error; ++ if (!ocrManager->isAvailable()) { ++ QAction *action = m_ocrLanguageMenu->addAction(initializationFailed ? i18n("OCR is not available. Please install Tesseract OCR.") ++ : i18n("OCR engine is initializing…")); ++ action->setEnabled(false); ++ return; ++ } ++ ++ const bool busy = ocrManager->status() == OcrManager::OcrStatus::Processing; ++ const QMap languages = ocrManager->availableLanguagesWithNames(); ++ ++ if (languages.isEmpty()) { ++ QAction *action = m_ocrLanguageMenu->addAction(i18n("No OCR language data available.")); ++ action->setEnabled(false); ++ return; ++ } ++ ++ for (auto it = languages.cbegin(); it != languages.cend(); ++it) { ++ const QString &code = it.key(); ++ ++ if (code == u"osd"_s) { ++ continue; ++ } ++ ++ QAction *languageAction = m_ocrLanguageMenu->addAction(it.value()); ++ languageAction->setEnabled(!busy); ++ ++ connect(languageAction, &QAction::triggered, this, [this, code]() { ++ triggerExtraction(code); ++ }); ++ } ++} ++ ++void ExportMenu::triggerExtraction(const QString &languageCode) ++{ ++ SpectacleCore::instance()->startOcrExtraction(languageCode); ++} ++ + #include "moc_ExportMenu.cpp" +diff --git a/src/Gui/ExportMenu.h b/src/Gui/ExportMenu.h +index e0533a708..bfac0b990 100644 +--- a/src/Gui/ExportMenu.h ++++ b/src/Gui/ExportMenu.h +@@ -9,6 +9,7 @@ + + #include "SpectacleMenu.h" + ++#include + #include + + #include "Config.h" +@@ -49,8 +50,11 @@ private: + + Q_SLOT void onImageChanged(); + Q_SLOT void openScreenshotsFolder(); ++ Q_SLOT void buildOcrLanguageSubmenu(); ++ Q_SLOT void triggerExtraction(const QString &languageCode); + + void getKServiceItems(); ++ void createOcrLanguageSubmenu(); + + #ifdef PURPOSE_FOUND + void loadPurposeMenu(); +@@ -59,6 +63,7 @@ private: + bool mUpdatedImageAvailable; + std::unique_ptr mPurposeMenu; + #endif ++ QMenu *m_ocrLanguageMenu = nullptr; + friend class ExportMenuSingleton; + }; + +diff --git a/src/Gui/OcrAction.qml b/src/Gui/OcrAction.qml +index f887ec0ee..a22efec16 100644 +--- a/src/Gui/OcrAction.qml ++++ b/src/Gui/OcrAction.qml +@@ -6,9 +6,10 @@ import QtQuick.Templates as T + import org.kde.spectacle.private + + T.Action { +- // OCR is only available for screenshots, not videos, and only when OCR is properly available +- enabled: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable ++ enabled: !SpectacleCore.videoMode && ++ SpectacleCore.ocrAvailable && ++ SpectacleCore.ocrStatus !== 1 + icon.name: "document-scan" + text: i18nc("@action", "Extract Text") +- onTriggered: contextWindow.extractText() ++ onTriggered: SpectacleCore.startOcrExtraction() + } +diff --git a/src/Gui/SettingsDialog/GeneralOptions.ui b/src/Gui/SettingsDialog/GeneralOptions.ui +index ddbbf3e5a..048639b89 100644 +--- a/src/Gui/SettingsDialog/GeneralOptions.ui ++++ b/src/Gui/SettingsDialog/GeneralOptions.ui +@@ -265,21 +265,39 @@ + + + +- Language: ++ Languages for OCR: + + + + +- +- +- +- 0 +- 0 +- ++ ++ ++ true + +- +- currentData ++ ++ QFrame::StyledPanel + ++ ++ 120 ++ ++ ++ 60 ++ ++ ++ Qt::ScrollBarAlwaysOff ++ ++ ++ ++ ++ 0 ++ 0 ++ 69 ++ 69 ++ ++ ++ ++ ++ + + + +@@ -383,7 +401,7 @@ + kcfg_useReleaseToCapture + kcfg_showCaptureInstructions + kcfg_rememberSelectionRect +- kcfg_ocrLanguage ++ ocrLanguageScrollArea + + + +diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +index 5b8a5d9fc..f6be13d56 100644 +--- a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp ++++ b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +@@ -8,19 +8,22 @@ + + #include "GeneralOptionsPage.h" + ++#include "OcrLanguageSelector.h" ++#include "OcrManager.h" + #include "settings.h" + #include "ui_GeneralOptions.h" +-#include "OcrManager.h" + +-#include + #include ++#include + +-#include + #include + ++using namespace Qt::Literals::StringLiterals; ++ + GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + : QWidget(parent) + , m_ui(new Ui_GeneralOptions) ++ , m_ocrLanguageSelector(new OcrLanguageSelector(this)) + { + m_ui->setupUi(this); + +@@ -31,9 +34,12 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + m_ui->regionTitle->setLevel(2); + m_ui->ocrTitle->setLevel(2); + +- setupOcrLanguageComboBox(); ++ m_ui->ocrLanguageScrollArea->setWidget(m_ocrLanguageSelector); ++ m_ui->ocrLanguageScrollArea->setWidgetResizable(true); ++ ++ connect(m_ocrLanguageSelector, &OcrLanguageSelector::selectedLanguagesChanged, this, &GeneralOptionsPage::ocrLanguageChanged); + +- connect(OcrManager::instance(), &OcrManager::statusChanged, this, &GeneralOptionsPage::refreshOcrLanguageSettings); ++ refreshOcrLanguageSettings(); + + //On Wayland we can't programmatically raise and focus the window so we have to hide the option + if (KWindowSystem::isPlatformWayland() || qstrcmp(qgetenv("XDG_SESSION_TYPE").constData(), "wayland") == 0) { +@@ -43,71 +49,20 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + + GeneralOptionsPage::~GeneralOptionsPage() = default; + +-void GeneralOptionsPage::setupOcrLanguageComboBox() +-{ +- OcrManager *ocrManager = OcrManager::instance(); +- +- if (!ocrManager->isAvailable()) { +- m_ui->kcfg_ocrLanguage->setEnabled(false); +- m_ui->kcfg_ocrLanguage->addItem(i18n("OCR not available")); +- m_ui->ocrLanguageLabel->setVisible(false); +- m_ui->kcfg_ocrLanguage->setVisible(false); +- m_ui->ocrUnavailableWidget->setVisible(true); +- return; +- } +- +- const auto availableLanguages = ocrManager->availableLanguagesWithNames(); +- +- if (availableLanguages.isEmpty()) { +- m_ui->kcfg_ocrLanguage->addItem(i18n("No languages found")); +- m_ui->kcfg_ocrLanguage->setEnabled(false); +- return; +- } +- +- m_ui->kcfg_ocrLanguage->clear(); +- m_ui->ocrLanguageLabel->setVisible(true); +- m_ui->kcfg_ocrLanguage->setVisible(true); +- m_ui->ocrUnavailableWidget->setVisible(false); +- +- for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) { +- m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key()); +- } +-} +- + void GeneralOptionsPage::refreshOcrLanguageSettings() + { + OcrManager *ocrManager = OcrManager::instance(); + + if (!ocrManager->isAvailable()) { + m_ui->ocrLanguageLabel->setVisible(false); +- m_ui->kcfg_ocrLanguage->setVisible(false); ++ m_ui->ocrLanguageScrollArea->setVisible(false); + m_ui->ocrUnavailableWidget->setVisible(true); +- return; +- } +- +- const auto availableLanguages = ocrManager->availableLanguagesWithNames(); +- +- if (availableLanguages.isEmpty()) { +- return; +- } +- +- m_ui->kcfg_ocrLanguage->clear(); +- m_ui->kcfg_ocrLanguage->setEnabled(true); +- m_ui->ocrLanguageLabel->setVisible(true); +- m_ui->kcfg_ocrLanguage->setVisible(true); +- m_ui->ocrUnavailableWidget->setVisible(false); +- +- for (auto it = availableLanguages.constBegin(); it != availableLanguages.constEnd(); ++it) { +- m_ui->kcfg_ocrLanguage->addItem(it.value(), it.key()); +- } +- +- const QString currentLanguage = Settings::ocrLanguage(); +- +- for (int i = 0; i < m_ui->kcfg_ocrLanguage->count(); ++i) { +- if (m_ui->kcfg_ocrLanguage->itemData(i).toString() == currentLanguage) { +- m_ui->kcfg_ocrLanguage->setCurrentIndex(i); +- break; +- } ++ } else { ++ m_ui->ocrLanguageLabel->setVisible(true); ++ m_ui->ocrLanguageScrollArea->setVisible(true); ++ m_ui->ocrUnavailableWidget->setVisible(false); ++ ++ m_ocrLanguageSelector->refresh(); + } + } + +diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.h b/src/Gui/SettingsDialog/GeneralOptionsPage.h +index c184d6ba8..a3a5cb17d 100644 +--- a/src/Gui/SettingsDialog/GeneralOptionsPage.h ++++ b/src/Gui/SettingsDialog/GeneralOptionsPage.h +@@ -11,6 +11,7 @@ + #include + + class Ui_GeneralOptions; ++class OcrLanguageSelector; + + class GeneralOptionsPage : public QWidget + { +@@ -22,10 +23,21 @@ public: + + void refreshOcrLanguageSettings(); + ++ /** ++ * @brief Get direct access to the OCR language selector widget ++ * @return Pointer to the OcrLanguageSelector widget for direct manipulation ++ */ ++ OcrLanguageSelector *ocrLanguageSelector() const ++ { ++ return m_ocrLanguageSelector; ++ } ++ ++Q_SIGNALS: ++ void ocrLanguageChanged(); ++ + private: +- void setupOcrLanguageComboBox(); +- + QScopedPointer m_ui; ++ OcrLanguageSelector *m_ocrLanguageSelector; + }; + + #endif // GENERALOPTIONSPAGE_H +diff --git a/src/Gui/SettingsDialog/OcrLanguageSelector.cpp b/src/Gui/SettingsDialog/OcrLanguageSelector.cpp +new file mode 100644 +index 000000000..d1d809323 +--- /dev/null ++++ b/src/Gui/SettingsDialog/OcrLanguageSelector.cpp +@@ -0,0 +1,271 @@ ++/* ++ * SPDX-FileCopyrightText: 2025 Jhair Paris ++ * ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#include "OcrLanguageSelector.h" ++#include "OcrManager.h" ++#include "settings.h" ++#include "spectacle_debug.h" ++ ++#include ++ ++#include ++#include ++ ++using namespace Qt::Literals::StringLiterals; ++ ++OcrLanguageSelector::OcrLanguageSelector(QWidget *parent) ++ : QWidget(parent) ++ , m_layout(new QVBoxLayout(this)) ++ , m_blockSignals(false) ++ , m_ocrManager(OcrManager::instance()) ++{ ++ m_layout->setContentsMargins(0, 0, 0, 0); ++ m_layout->setSpacing(0); ++ setContentsMargins(0, 0, 0, 0); ++ ++ setupLanguageCheckboxes(); ++ ++ connect(m_ocrManager, &OcrManager::statusChanged, this, &OcrLanguageSelector::onOcrManagerStatusChanged); ++} ++ ++OcrLanguageSelector::~OcrLanguageSelector() = default; ++ ++QStringList OcrLanguageSelector::selectedLanguages() const ++{ ++ QStringList result; ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ if (checkbox->isChecked()) { ++ result.append(checkbox->property("languageCode").toString()); ++ } ++ } ++ return result; ++} ++ ++void OcrLanguageSelector::setSelectedLanguages(const QStringList &languages) ++{ ++ m_blockSignals = true; ++ ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ const QString langCode = checkbox->property("languageCode").toString(); ++ checkbox->setChecked(languages.contains(langCode)); ++ } ++ ++ m_blockSignals = false; ++ ++ enforceSelectionLimits(); ++} ++ ++bool OcrLanguageSelector::isDefault() const ++{ ++ const QStringList current = selectedLanguages(); ++ ++ // Default state is exactly one language selected ++ if (current.size() != 1) { ++ return false; ++ } ++ ++ // Check if it's English (preferred default) ++ for (const QCheckBox *checkbox : m_languageCheckboxes) { ++ if (checkbox->property("languageCode").toString() == u"eng"_s) { ++ // English is available, so default is English ++ return current.contains(u"eng"_s); ++ } ++ } ++ ++ // English not available, default is the first available language ++ if (!m_languageCheckboxes.isEmpty()) { ++ QString firstLangCode = m_languageCheckboxes.first()->property("languageCode").toString(); ++ return current.contains(firstLangCode); ++ } ++ ++ return false; ++} ++ ++bool OcrLanguageSelector::hasChanges() const ++{ ++ return selectedLanguages() != Settings::ocrLanguages(); ++} ++ ++void OcrLanguageSelector::applyDefaults() ++{ ++ if (!m_languageCheckboxes.isEmpty()) { ++ m_blockSignals = true; ++ ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ checkbox->setChecked(false); ++ } ++ ++ // Try to select English first ++ bool foundDefault = false; ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ if (checkbox->property("languageCode").toString() == u"eng"_s) { ++ checkbox->setChecked(true); ++ foundDefault = true; ++ break; ++ } ++ } ++ ++ // If English not available, select first language ++ if (!foundDefault) { ++ m_languageCheckboxes.first()->setChecked(true); ++ } ++ ++ m_blockSignals = false; ++ ++ const QStringList selected = selectedLanguages(); ++ Settings::setOcrLanguages(selected); ++ ++ // Emit signal to notify changes ++ Q_EMIT selectedLanguagesChanged(selected); ++ } ++} ++ ++void OcrLanguageSelector::refresh() ++{ ++ setupLanguageCheckboxes(); ++} ++ ++void OcrLanguageSelector::saveSettings() ++{ ++ const QStringList selected = selectedLanguages(); ++ Settings::setOcrLanguages(selected); ++} ++ ++void OcrLanguageSelector::updateWidgets() ++{ ++ const QStringList savedLanguages = Settings::ocrLanguages(); ++ setSelectedLanguages(savedLanguages); ++} ++ ++void OcrLanguageSelector::onLanguageCheckboxChanged() ++{ ++ if (m_blockSignals) { ++ return; ++ } ++ ++ enforceSelectionLimits(); ++ ++ const QStringList selected = selectedLanguages(); ++ Q_EMIT selectedLanguagesChanged(selected); ++} ++ ++void OcrLanguageSelector::onOcrManagerStatusChanged() ++{ ++ refresh(); ++} ++ ++void OcrLanguageSelector::setupLanguageCheckboxes() ++{ ++ while (QLayoutItem *item = m_layout->takeAt(0)) { ++ if (auto widget = item->widget()) { ++ widget->deleteLater(); ++ } ++ delete item; ++ } ++ ++ m_languageCheckboxes.clear(); ++ m_availableLanguages.clear(); ++ ++ if (!m_ocrManager || !m_ocrManager->isAvailable()) { ++ qCWarning(SPECTACLE_LOG) << "OCR is not available; language selector will remain empty."; ++ return; ++ } ++ ++ m_availableLanguages = m_ocrManager->availableLanguagesWithNames(); ++ ++ if (m_availableLanguages.isEmpty()) { ++ qCWarning(SPECTACLE_LOG) << "No OCR language data available."; ++ return; ++ } ++ ++ for (auto it = m_availableLanguages.cbegin(); it != m_availableLanguages.cend(); ++it) { ++ const QString &langCode = it.key(); ++ if (langCode == u"osd"_s) { ++ continue; ++ } ++ ++ QCheckBox *checkbox = new QCheckBox(it.value(), this); ++ checkbox->setProperty("languageCode", langCode); ++ connect(checkbox, &QCheckBox::toggled, this, &OcrLanguageSelector::onLanguageCheckboxChanged); ++ m_layout->addWidget(checkbox); ++ m_languageCheckboxes.append(checkbox); ++ } ++ ++ if (m_layout->count() > 0) { ++ m_layout->addStretch(); ++ } ++ ++ const QStringList savedLanguages = Settings::ocrLanguages(); ++ setSelectedLanguages(savedLanguages); ++ ++ if (savedLanguages.isEmpty() && !m_languageCheckboxes.isEmpty()) { ++ applyDefaults(); ++ } ++} ++ ++void OcrLanguageSelector::enforceSelectionLimits() ++{ ++ const QStringList selected = selectedLanguages(); ++ const int count = selected.size(); ++ ++ if (count > OcrManager::MAX_OCR_LANGUAGES) { // Max languages for performance ++ for (int i = m_languageCheckboxes.size() - 1; i >= 0; --i) { ++ QCheckBox *checkbox = m_languageCheckboxes[i]; ++ if (checkbox->isChecked()) { ++ blockSignalsAndSetChecked(checkbox, false); ++ break; ++ } ++ } ++ } ++ ++ updateCheckboxEnabledStates(); ++ ++ if (selectedLanguages().size() == 0 && !m_languageCheckboxes.isEmpty()) { ++ applyDefaults(); ++ } ++} ++ ++QString OcrLanguageSelector::getDefaultLanguageCode() const ++{ ++ if (m_languageCheckboxes.isEmpty()) { ++ return QString(); ++ } ++ ++ // Try English first ++ for (const QCheckBox *checkbox : m_languageCheckboxes) { ++ if (checkbox->property("languageCode").toString() == u"eng"_s) { ++ return u"eng"_s; ++ } ++ } ++ ++ // Fallback to first available ++ return m_languageCheckboxes.first()->property("languageCode").toString(); ++} ++ ++void OcrLanguageSelector::updateCheckboxEnabledStates() ++{ ++ const QStringList selected = selectedLanguages(); ++ const int count = selected.size(); ++ ++ // If we have max languages selected, disable all unchecked checkboxes ++ // If we have less than max, enable all checkboxes ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ if (checkbox->isChecked()) { ++ checkbox->setEnabled(true); ++ } else { ++ checkbox->setEnabled(count < OcrManager::MAX_OCR_LANGUAGES); ++ } ++ } ++} ++ ++void OcrLanguageSelector::blockSignalsAndSetChecked(QCheckBox *checkbox, bool checked) ++{ ++ m_blockSignals = true; ++ checkbox->setChecked(checked); ++ m_blockSignals = false; ++} ++ ++#include "moc_OcrLanguageSelector.cpp" +\ No newline at end of file +diff --git a/src/Gui/SettingsDialog/OcrLanguageSelector.h b/src/Gui/SettingsDialog/OcrLanguageSelector.h +new file mode 100644 +index 000000000..59b1a3d42 +--- /dev/null ++++ b/src/Gui/SettingsDialog/OcrLanguageSelector.h +@@ -0,0 +1,111 @@ ++/* ++ * SPDX-FileCopyrightText: 2025 Jhair Paris ++ * ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++#ifndef OCRLANGUAGESELECTOR_H ++#define OCRLANGUAGESELECTOR_H ++ ++#include ++#include ++#include ++ ++class OcrManager; ++ ++/** ++ * @brief Specialized widget for OCR language selection with multi-language support ++ * ++ * This widget encapsulates all the logic for OCR language selection: ++ * - Displays available languages as checkboxes (excluding 'osd') ++ * - Enforces limits: minimum 1, maximum languages defined by OcrManager ++ * - Handles defaults: English preferred, fallback to first available ++ * - Follows KConfigDialog pattern: no auto-persistence, explicit save/update methods ++ * - Updates dynamically when OCR manager state changes ++ */ ++class OcrLanguageSelector : public QWidget ++{ ++ Q_OBJECT ++ Q_PROPERTY(QStringList selectedLanguages READ selectedLanguages WRITE setSelectedLanguages NOTIFY selectedLanguagesChanged USER true) ++ Q_PROPERTY(bool isDefault READ isDefault NOTIFY selectedLanguagesChanged) ++ Q_PROPERTY(bool hasChanges READ hasChanges NOTIFY selectedLanguagesChanged) ++ ++public: ++ explicit OcrLanguageSelector(QWidget *parent = nullptr); ++ ~OcrLanguageSelector() override; ++ ++ /** ++ * @brief Get currently selected language codes ++ * @return List of selected language codes (e.g., ["eng", "spa"]) ++ */ ++ QStringList selectedLanguages() const; ++ ++ /** ++ * @brief Set selected languages ++ * @param languages List of language codes to select ++ */ ++ void setSelectedLanguages(const QStringList &languages); ++ ++ /** ++ * @brief Check if current selection is the default state ++ * @return true if selection represents default configuration ++ */ ++ bool isDefault() const; ++ ++ /** ++ * @brief Check if there are unsaved changes ++ * @return true if current selection differs from saved configuration ++ */ ++ bool hasChanges() const; ++ ++ /** ++ * @brief Apply default language selection ++ * Selects English if available, otherwise first available language ++ */ ++ void applyDefaults(); ++ ++ /** ++ * @brief Refresh the widget when OCR manager state changes ++ * Rebuilds checkboxes based on current available languages ++ */ ++ void refresh(); ++ ++ /** ++ * @brief Save current selection to settings (called by KConfigDialog) ++ * Follows KConfigDialog pattern for saving changes ++ */ ++ void saveSettings(); ++ ++ /** ++ * @brief Update widget to reflect current settings (called by KConfigDialog) ++ * Reloads settings when user cancels or dialog is reopened ++ */ ++ void updateWidgets(); ++ ++Q_SIGNALS: ++ /** ++ * @brief Emitted when language selection changes ++ * @param languages New list of selected languages ++ */ ++ void selectedLanguagesChanged(const QStringList &languages); ++ ++private Q_SLOTS: ++ void onLanguageCheckboxChanged(); ++ void onOcrManagerStatusChanged(); ++ ++private: ++ void setupLanguageCheckboxes(); ++ void enforceSelectionLimits(); ++ void updateCheckboxEnabledStates(); ++ QString getDefaultLanguageCode() const; ++ void blockSignalsAndSetChecked(QCheckBox *checkbox, bool checked); ++ ++ QVBoxLayout *m_layout; ++ QList m_languageCheckboxes; ++ QMap m_availableLanguages; // code -> display name ++ bool m_blockSignals; ++ ++ OcrManager *m_ocrManager; ++}; ++ ++#endif // OCRLANGUAGESELECTOR_H +\ No newline at end of file +diff --git a/src/Gui/SettingsDialog/SettingsDialog.cpp b/src/Gui/SettingsDialog/SettingsDialog.cpp +index a19a47627..532bfd3c3 100644 +--- a/src/Gui/SettingsDialog/SettingsDialog.cpp ++++ b/src/Gui/SettingsDialog/SettingsDialog.cpp +@@ -1,4 +1,5 @@ + /* ++ * SPDX-FileCopyrightText: 2025 Jhair Paris + * SPDX-FileCopyrightText: 2019 David Redondo + * SPDX-FileCopyrightText: 2015 Boudhayan Gupta + * +@@ -9,8 +10,9 @@ + + #include "GeneralOptionsPage.h" + #include "ImageSaveOptionsPage.h" +-#include "VideoSaveOptionsPage.h" ++#include "OcrLanguageSelector.h" + #include "ShortcutsOptionsPage.h" ++#include "VideoSaveOptionsPage.h" + #include "settings.h" + + #include +@@ -38,6 +40,9 @@ SettingsDialog::SettingsDialog(QWidget *parent) + connect(m_shortcutsPage, &ShortcutsOptionsPage::shortCutsChanged, this, [this] { + updateButtons(); + }); ++ connect(m_generalPage, &GeneralOptionsPage::ocrLanguageChanged, this, [this] { ++ updateButtons(); ++ }); + connect(this, &KConfigDialog::currentPageChanged, this, &SettingsDialog::updateButtons); + } + +@@ -72,18 +77,20 @@ void SettingsDialog::showEvent(QShowEvent *event) + + bool SettingsDialog::hasChanged() + { +- return m_shortcutsPage->isModified() || KConfigDialog::hasChanged(); ++ return m_shortcutsPage->isModified() || m_generalPage->ocrLanguageSelector()->hasChanges() || KConfigDialog::hasChanged(); + } + + bool SettingsDialog::isDefault() + { +- return currentPage()->name() != i18n("Shortcuts") && KConfigDialog::isDefault(); ++ return currentPage()->name() != i18n("Shortcuts") && m_generalPage->ocrLanguageSelector()->isDefault() && KConfigDialog::isDefault(); + } + + void SettingsDialog::updateSettings() + { + KConfigDialog::updateSettings(); + m_shortcutsPage->saveChanges(); ++ ++ m_generalPage->ocrLanguageSelector()->saveSettings(); + } + + void SettingsDialog::updateWidgets() +@@ -91,6 +98,7 @@ void SettingsDialog::updateWidgets() + KConfigDialog::updateWidgets(); + m_shortcutsPage->resetChanges(); + ++ m_generalPage->ocrLanguageSelector()->updateWidgets(); + m_generalPage->refreshOcrLanguageSettings(); + } + +@@ -98,6 +106,9 @@ void SettingsDialog::updateWidgetsDefault() + { + KConfigDialog::updateWidgetsDefault(); + m_shortcutsPage->defaults(); ++ ++ m_generalPage->ocrLanguageSelector()->applyDefaults(); ++ m_generalPage->refreshOcrLanguageSettings(); + } + + #include "moc_SettingsDialog.cpp" +diff --git a/src/Gui/SettingsDialog/spectacle.kcfg b/src/Gui/SettingsDialog/spectacle.kcfg +index 4517e2344..2062f7cc4 100644 +--- a/src/Gui/SettingsDialog/spectacle.kcfg ++++ b/src/Gui/SettingsDialog/spectacle.kcfg +@@ -70,8 +70,8 @@ + + UntilClosed + +- +- ++ ++ + eng + + +diff --git a/src/Gui/ViewerPage.qml b/src/Gui/ViewerPage.qml +index 602e4431b..133793964 100644 +--- a/src/Gui/ViewerPage.qml ++++ b/src/Gui/ViewerPage.qml +@@ -61,11 +61,13 @@ EmptyPage { + visible: action.enabled + action: CopyImageAction {} + } ++ + TtToolButton { + display: TtToolButton.IconOnly +- visible: action.enabled && SpectacleCore.ocrAvailable ++ visible: !SpectacleCore.videoMode && SpectacleCore.ocrAvailable + action: OcrAction {} + } ++ + // We only show this in video mode to save space in screenshot mode + TtToolButton { + visible: SpectacleCore.videoMode +-- +GitLab + diff --git a/roles/kde/patches/spectacle/pr487.patch b/roles/kde/patches/spectacle/pr487.patch new file mode 100644 index 0000000..759e43a --- /dev/null +++ b/roles/kde/patches/spectacle/pr487.patch @@ -0,0 +1,704 @@ +From d72a6fcb76053139ea709d7b1a4f45aa430a066d Mon Sep 17 00:00:00 2001 +From: Jhair Paris +Date: Sat, 8 Nov 2025 17:32:20 -0500 +Subject: [PATCH 1/4] Enhance OCR language settings management and + synchronization + +- Improved signal handling in OcrLanguageSelector to prevent unnecessary updates. +- Added config sync suspension functionality in OcrManager to manage settings changes. +- Adjusted SettingsDialog to ensure proper synchronization of OCR configurations. +--- + src/Gui/SettingsDialog/GeneralOptionsPage.cpp | 7 +- + src/Gui/SettingsDialog/GeneralOptionsPage.h | 4 +- + .../SettingsDialog/OcrLanguageSelector.cpp | 132 ++++++++---------- + src/Gui/SettingsDialog/OcrLanguageSelector.h | 7 +- + src/Gui/SettingsDialog/SettingsDialog.cpp | 25 +++- + src/Gui/SettingsDialog/SettingsDialog.h | 1 + + src/OcrManager.cpp | 25 ++++ + src/OcrManager.h | 3 + + 8 files changed, 117 insertions(+), 87 deletions(-) + +diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +index f6be13d56..ae0a997c3 100644 +--- a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp ++++ b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +@@ -10,7 +10,6 @@ + + #include "OcrLanguageSelector.h" + #include "OcrManager.h" +-#include "settings.h" + #include "ui_GeneralOptions.h" + + #include +@@ -49,7 +48,7 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + + GeneralOptionsPage::~GeneralOptionsPage() = default; + +-void GeneralOptionsPage::refreshOcrLanguageSettings() ++void GeneralOptionsPage::refreshOcrLanguageSettings(bool rebuildSelector) + { + OcrManager *ocrManager = OcrManager::instance(); + +@@ -62,7 +61,9 @@ void GeneralOptionsPage::refreshOcrLanguageSettings() + m_ui->ocrLanguageScrollArea->setVisible(true); + m_ui->ocrUnavailableWidget->setVisible(false); + +- m_ocrLanguageSelector->refresh(); ++ if (rebuildSelector) { ++ m_ocrLanguageSelector->refresh(); ++ } + } + } + +diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.h b/src/Gui/SettingsDialog/GeneralOptionsPage.h +index a3a5cb17d..bbb6d79a8 100644 +--- a/src/Gui/SettingsDialog/GeneralOptionsPage.h ++++ b/src/Gui/SettingsDialog/GeneralOptionsPage.h +@@ -20,8 +20,8 @@ class GeneralOptionsPage : public QWidget + public: + explicit GeneralOptionsPage(QWidget *parent = nullptr); + ~GeneralOptionsPage() override; +- +- void refreshOcrLanguageSettings(); ++ ++ void refreshOcrLanguageSettings(bool rebuildSelector = true); + + /** + * @brief Get direct access to the OCR language selector widget +diff --git a/src/Gui/SettingsDialog/OcrLanguageSelector.cpp b/src/Gui/SettingsDialog/OcrLanguageSelector.cpp +index d1d809323..815b4c707 100644 +--- a/src/Gui/SettingsDialog/OcrLanguageSelector.cpp ++++ b/src/Gui/SettingsDialog/OcrLanguageSelector.cpp +@@ -12,6 +12,7 @@ + #include + + #include ++#include + #include + + using namespace Qt::Literals::StringLiterals; +@@ -19,7 +20,6 @@ using namespace Qt::Literals::StringLiterals; + OcrLanguageSelector::OcrLanguageSelector(QWidget *parent) + : QWidget(parent) + , m_layout(new QVBoxLayout(this)) +- , m_blockSignals(false) + , m_ocrManager(OcrManager::instance()) + { + m_layout->setContentsMargins(0, 0, 0, 0); +@@ -46,15 +46,14 @@ QStringList OcrLanguageSelector::selectedLanguages() const + + void OcrLanguageSelector::setSelectedLanguages(const QStringList &languages) + { +- m_blockSignals = true; ++ QSignalBlocker blocker(this); + + for (QCheckBox *checkbox : m_languageCheckboxes) { + const QString langCode = checkbox->property("languageCode").toString(); ++ QSignalBlocker checkboxBlocker(checkbox); + checkbox->setChecked(languages.contains(langCode)); + } + +- m_blockSignals = false; +- + enforceSelectionLimits(); + } + +@@ -67,18 +66,11 @@ bool OcrLanguageSelector::isDefault() const + return false; + } + +- // Check if it's English (preferred default) +- for (const QCheckBox *checkbox : m_languageCheckboxes) { +- if (checkbox->property("languageCode").toString() == u"eng"_s) { +- // English is available, so default is English +- return current.contains(u"eng"_s); +- } +- } ++ QCheckBox *defaultCheckbox = findDefaultCheckbox(); + +- // English not available, default is the first available language +- if (!m_languageCheckboxes.isEmpty()) { +- QString firstLangCode = m_languageCheckboxes.first()->property("languageCode").toString(); +- return current.contains(firstLangCode); ++ if (defaultCheckbox) { ++ QString defaultLangCode = defaultCheckbox->property("languageCode").toString(); ++ return current.contains(defaultLangCode); + } + + return false; +@@ -91,36 +83,28 @@ bool OcrLanguageSelector::hasChanges() const + + void OcrLanguageSelector::applyDefaults() + { +- if (!m_languageCheckboxes.isEmpty()) { +- m_blockSignals = true; +- +- for (QCheckBox *checkbox : m_languageCheckboxes) { +- checkbox->setChecked(false); +- } ++ if (m_languageCheckboxes.isEmpty()) { ++ return; ++ } + +- // Try to select English first +- bool foundDefault = false; +- for (QCheckBox *checkbox : m_languageCheckboxes) { +- if (checkbox->property("languageCode").toString() == u"eng"_s) { +- checkbox->setChecked(true); +- foundDefault = true; +- break; +- } +- } ++ QSignalBlocker blocker(this); + +- // If English not available, select first language +- if (!foundDefault) { +- m_languageCheckboxes.first()->setChecked(true); +- } ++ QCheckBox *defaultCheckbox = findDefaultCheckbox(); + +- m_blockSignals = false; ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ QSignalBlocker checkboxBlocker(checkbox); ++ checkbox->setChecked(checkbox == defaultCheckbox); ++ } + +- const QStringList selected = selectedLanguages(); +- Settings::setOcrLanguages(selected); ++ const int selectedCount = defaultCheckbox ? 1 : 0; ++ updateCheckboxEnabledStates(selectedCount); + +- // Emit signal to notify changes +- Q_EMIT selectedLanguagesChanged(selected); ++ QStringList selected; ++ if (defaultCheckbox) { ++ selected.append(defaultCheckbox->property("languageCode").toString()); + } ++ ++ Q_EMIT selectedLanguagesChanged(selected); + } + + void OcrLanguageSelector::refresh() +@@ -142,13 +126,17 @@ void OcrLanguageSelector::updateWidgets() + + void OcrLanguageSelector::onLanguageCheckboxChanged() + { +- if (m_blockSignals) { +- return; +- } +- + enforceSelectionLimits(); + +- const QStringList selected = selectedLanguages(); ++ QStringList selected; ++ selected.reserve(OcrManager::MAX_OCR_LANGUAGES); ++ ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ if (checkbox->isChecked()) { ++ selected.append(checkbox->property("languageCode").toString()); ++ } ++ } ++ + Q_EMIT selectedLanguagesChanged(selected); + } + +@@ -167,21 +155,20 @@ void OcrLanguageSelector::setupLanguageCheckboxes() + } + + m_languageCheckboxes.clear(); +- m_availableLanguages.clear(); + + if (!m_ocrManager || !m_ocrManager->isAvailable()) { + qCWarning(SPECTACLE_LOG) << "OCR is not available; language selector will remain empty."; + return; + } + +- m_availableLanguages = m_ocrManager->availableLanguagesWithNames(); ++ const QMap availableLanguages = m_ocrManager->availableLanguagesWithNames(); + +- if (m_availableLanguages.isEmpty()) { ++ if (availableLanguages.isEmpty()) { + qCWarning(SPECTACLE_LOG) << "No OCR language data available."; + return; + } + +- for (auto it = m_availableLanguages.cbegin(); it != m_availableLanguages.cend(); ++it) { ++ for (auto it = availableLanguages.cbegin(); it != availableLanguages.cend(); ++it) { + const QString &langCode = it.key(); + if (langCode == u"osd"_s) { + continue; +@@ -208,64 +195,57 @@ void OcrLanguageSelector::setupLanguageCheckboxes() + + void OcrLanguageSelector::enforceSelectionLimits() + { +- const QStringList selected = selectedLanguages(); +- const int count = selected.size(); ++ int selectedCount = 0; ++ ++ for (QCheckBox *checkbox : m_languageCheckboxes) { ++ if (checkbox->isChecked()) { ++ ++selectedCount; ++ } ++ } + +- if (count > OcrManager::MAX_OCR_LANGUAGES) { // Max languages for performance ++ if (selectedCount > OcrManager::MAX_OCR_LANGUAGES) { + for (int i = m_languageCheckboxes.size() - 1; i >= 0; --i) { + QCheckBox *checkbox = m_languageCheckboxes[i]; + if (checkbox->isChecked()) { +- blockSignalsAndSetChecked(checkbox, false); ++ QSignalBlocker blocker(checkbox); ++ checkbox->setChecked(false); ++ --selectedCount; + break; + } + } + } + +- updateCheckboxEnabledStates(); ++ updateCheckboxEnabledStates(selectedCount); + +- if (selectedLanguages().size() == 0 && !m_languageCheckboxes.isEmpty()) { ++ if (selectedCount == 0 && !m_languageCheckboxes.isEmpty()) { + applyDefaults(); + } + } + +-QString OcrLanguageSelector::getDefaultLanguageCode() const ++QCheckBox *OcrLanguageSelector::findDefaultCheckbox() const + { + if (m_languageCheckboxes.isEmpty()) { +- return QString(); ++ return nullptr; + } + + // Try English first +- for (const QCheckBox *checkbox : m_languageCheckboxes) { ++ for (QCheckBox *checkbox : m_languageCheckboxes) { + if (checkbox->property("languageCode").toString() == u"eng"_s) { +- return u"eng"_s; ++ return checkbox; + } + } + + // Fallback to first available +- return m_languageCheckboxes.first()->property("languageCode").toString(); ++ return m_languageCheckboxes.first(); + } + +-void OcrLanguageSelector::updateCheckboxEnabledStates() ++void OcrLanguageSelector::updateCheckboxEnabledStates(int selectedCount) + { +- const QStringList selected = selectedLanguages(); +- const int count = selected.size(); ++ const bool enableUnchecked = selectedCount < OcrManager::MAX_OCR_LANGUAGES; + +- // If we have max languages selected, disable all unchecked checkboxes +- // If we have less than max, enable all checkboxes + for (QCheckBox *checkbox : m_languageCheckboxes) { +- if (checkbox->isChecked()) { +- checkbox->setEnabled(true); +- } else { +- checkbox->setEnabled(count < OcrManager::MAX_OCR_LANGUAGES); +- } ++ checkbox->setEnabled(checkbox->isChecked() || enableUnchecked); + } + } + +-void OcrLanguageSelector::blockSignalsAndSetChecked(QCheckBox *checkbox, bool checked) +-{ +- m_blockSignals = true; +- checkbox->setChecked(checked); +- m_blockSignals = false; +-} +- + #include "moc_OcrLanguageSelector.cpp" +\ No newline at end of file +diff --git a/src/Gui/SettingsDialog/OcrLanguageSelector.h b/src/Gui/SettingsDialog/OcrLanguageSelector.h +index 59b1a3d42..e938e06fa 100644 +--- a/src/Gui/SettingsDialog/OcrLanguageSelector.h ++++ b/src/Gui/SettingsDialog/OcrLanguageSelector.h +@@ -96,14 +96,11 @@ private Q_SLOTS: + private: + void setupLanguageCheckboxes(); + void enforceSelectionLimits(); +- void updateCheckboxEnabledStates(); +- QString getDefaultLanguageCode() const; +- void blockSignalsAndSetChecked(QCheckBox *checkbox, bool checked); ++ void updateCheckboxEnabledStates(int selectedCount); ++ QCheckBox *findDefaultCheckbox() const; + + QVBoxLayout *m_layout; + QList m_languageCheckboxes; +- QMap m_availableLanguages; // code -> display name +- bool m_blockSignals; + + OcrManager *m_ocrManager; + }; +diff --git a/src/Gui/SettingsDialog/SettingsDialog.cpp b/src/Gui/SettingsDialog/SettingsDialog.cpp +index 532bfd3c3..696636685 100644 +--- a/src/Gui/SettingsDialog/SettingsDialog.cpp ++++ b/src/Gui/SettingsDialog/SettingsDialog.cpp +@@ -11,6 +11,7 @@ + #include "GeneralOptionsPage.h" + #include "ImageSaveOptionsPage.h" + #include "OcrLanguageSelector.h" ++#include "OcrManager.h" + #include "ShortcutsOptionsPage.h" + #include "VideoSaveOptionsPage.h" + #include "settings.h" +@@ -46,6 +47,16 @@ SettingsDialog::SettingsDialog(QWidget *parent) + connect(this, &KConfigDialog::currentPageChanged, this, &SettingsDialog::updateButtons); + } + ++SettingsDialog::~SettingsDialog() ++{ ++ // Ensure OCR config sync is resumed ++ if (OcrManager *ocrManager = OcrManager::instance()) { ++ if (ocrManager->isConfigSyncSuspended()) { ++ ocrManager->setConfigSyncSuspended(false); ++ } ++ } ++} ++ + QSize SettingsDialog::sizeHint() const + { + // Avoid having pages that need to be scrolled, +@@ -91,6 +102,10 @@ void SettingsDialog::updateSettings() + m_shortcutsPage->saveChanges(); + + m_generalPage->ocrLanguageSelector()->saveSettings(); ++ ++ if (OcrManager *ocrManager = OcrManager::instance()) { ++ ocrManager->setConfigSyncSuspended(false); ++ } + } + + void SettingsDialog::updateWidgets() +@@ -100,15 +115,23 @@ void SettingsDialog::updateWidgets() + + m_generalPage->ocrLanguageSelector()->updateWidgets(); + m_generalPage->refreshOcrLanguageSettings(); ++ ++ if (OcrManager *ocrManager = OcrManager::instance()) { ++ ocrManager->setConfigSyncSuspended(false); ++ } + } + + void SettingsDialog::updateWidgetsDefault() + { ++ if (OcrManager *ocrManager = OcrManager::instance()) { ++ ocrManager->setConfigSyncSuspended(true); ++ } ++ + KConfigDialog::updateWidgetsDefault(); + m_shortcutsPage->defaults(); + + m_generalPage->ocrLanguageSelector()->applyDefaults(); +- m_generalPage->refreshOcrLanguageSettings(); ++ m_generalPage->refreshOcrLanguageSettings(false); + } + + #include "moc_SettingsDialog.cpp" +diff --git a/src/Gui/SettingsDialog/SettingsDialog.h b/src/Gui/SettingsDialog/SettingsDialog.h +index 50f6d85bf..64281dd09 100644 +--- a/src/Gui/SettingsDialog/SettingsDialog.h ++++ b/src/Gui/SettingsDialog/SettingsDialog.h +@@ -20,6 +20,7 @@ class SettingsDialog : public KConfigDialog + + public: + explicit SettingsDialog(QWidget *parent = nullptr); ++ ~SettingsDialog() override; + + protected: + QSize sizeHint() const override; +diff --git a/src/OcrManager.cpp b/src/OcrManager.cpp +index 1d09db8ef..56d467993 100644 +--- a/src/OcrManager.cpp ++++ b/src/OcrManager.cpp +@@ -56,6 +56,9 @@ OcrManager::OcrManager(QObject *parent) + m_workerThread->start(); + + connect(Settings::self(), &Settings::ocrLanguagesChanged, this, [this]() { ++ if (m_configSyncSuspended) { ++ return; ++ } + const QStringList newLanguages = Settings::ocrLanguages(); + const QString combinedLanguages = newLanguages.join(u"+"_s); + if (combinedLanguages != m_currentLanguageCode) { +@@ -150,6 +153,28 @@ QString OcrManager::currentLanguageCode() const + return m_currentLanguageCode; + } + ++void OcrManager::setConfigSyncSuspended(bool suspended) ++{ ++ if (m_configSyncSuspended == suspended) { ++ return; ++ } ++ ++ m_configSyncSuspended = suspended; ++ ++ // On resume, apply any changes made to Settings ++ if (!m_configSyncSuspended) { ++ const QStringList settingsLanguages = Settings::ocrLanguages(); ++ if (settingsLanguages != m_configuredLanguages) { ++ setLanguagesByCode(settingsLanguages); ++ } ++ } ++} ++ ++bool OcrManager::isConfigSyncSuspended() const ++{ ++ return m_configSyncSuspended; ++} ++ + void OcrManager::recognizeText(const QImage &image) + { + #ifdef HAVE_TESSERACT_OCR +diff --git a/src/OcrManager.h b/src/OcrManager.h +index c71505b3e..37f490600 100644 +--- a/src/OcrManager.h ++++ b/src/OcrManager.h +@@ -98,6 +98,8 @@ public: + * @return Current language code (e.g., "eng", "spa") + */ + QString currentLanguageCode() const; ++ void setConfigSyncSuspended(bool suspended); ++ bool isConfigSyncSuspended() const; + + public Q_SLOTS: + /** +@@ -169,6 +171,7 @@ private: + bool m_shouldRestoreToConfigured; + QStringList m_availableLanguages; + QMap m_languageNames; ++ bool m_configSyncSuspended = false; + bool m_initialized; + + private: +-- +GitLab + + +From 642600410714c783515f2416a9be08ef3406b0d9 Mon Sep 17 00:00:00 2001 +From: Jhair Paris +Date: Sat, 8 Nov 2025 21:30:41 -0500 +Subject: [PATCH 2/4] Fix flickering in settings dialog during OCR + initialization + +--- + src/Gui/SettingsDialog/GeneralOptionsPage.cpp | 13 +++++++------ + 1 file changed, 7 insertions(+), 6 deletions(-) + +diff --git a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +index ae0a997c3..adfc045e6 100644 +--- a/src/Gui/SettingsDialog/GeneralOptionsPage.cpp ++++ b/src/Gui/SettingsDialog/GeneralOptionsPage.cpp +@@ -38,7 +38,7 @@ GeneralOptionsPage::GeneralOptionsPage(QWidget *parent) + + connect(m_ocrLanguageSelector, &OcrLanguageSelector::selectedLanguagesChanged, this, &GeneralOptionsPage::ocrLanguageChanged); + +- refreshOcrLanguageSettings(); ++ refreshOcrLanguageSettings(false); + + //On Wayland we can't programmatically raise and focus the window so we have to hide the option + if (KWindowSystem::isPlatformWayland() || qstrcmp(qgetenv("XDG_SESSION_TYPE").constData(), "wayland") == 0) { +@@ -51,8 +51,9 @@ GeneralOptionsPage::~GeneralOptionsPage() = default; + void GeneralOptionsPage::refreshOcrLanguageSettings(bool rebuildSelector) + { + OcrManager *ocrManager = OcrManager::instance(); +- +- if (!ocrManager->isAvailable()) { ++ const bool ocrAvailable = ocrManager->isAvailable(); ++ ++ if (!ocrAvailable) { + m_ui->ocrLanguageLabel->setVisible(false); + m_ui->ocrLanguageScrollArea->setVisible(false); + m_ui->ocrUnavailableWidget->setVisible(true); +@@ -60,10 +61,10 @@ void GeneralOptionsPage::refreshOcrLanguageSettings(bool rebuildSelector) + m_ui->ocrLanguageLabel->setVisible(true); + m_ui->ocrLanguageScrollArea->setVisible(true); + m_ui->ocrUnavailableWidget->setVisible(false); ++ } + +- if (rebuildSelector) { +- m_ocrLanguageSelector->refresh(); +- } ++ if (ocrAvailable && rebuildSelector) { ++ m_ocrLanguageSelector->refresh(); + } + } + +-- +GitLab + + +From 6c6d95f3fd87ff70f8c1d25786a5a7f047e9d74f Mon Sep 17 00:00:00 2001 +From: Jhair Paris +Date: Sat, 8 Nov 2025 21:51:05 -0500 +Subject: [PATCH 3/4] Improve OCR language handling in settings dialog + +--- + src/Gui/SettingsDialog/SettingsDialog.cpp | 24 ++++++++++++++++++----- + 1 file changed, 19 insertions(+), 5 deletions(-) + +diff --git a/src/Gui/SettingsDialog/SettingsDialog.cpp b/src/Gui/SettingsDialog/SettingsDialog.cpp +index 696636685..0bfab8bb9 100644 +--- a/src/Gui/SettingsDialog/SettingsDialog.cpp ++++ b/src/Gui/SettingsDialog/SettingsDialog.cpp +@@ -88,12 +88,20 @@ void SettingsDialog::showEvent(QShowEvent *event) + + bool SettingsDialog::hasChanged() + { +- return m_shortcutsPage->isModified() || m_generalPage->ocrLanguageSelector()->hasChanges() || KConfigDialog::hasChanged(); ++ bool ocrHasChanges = false; ++ if (OcrManager::instance()->isAvailable()) { ++ ocrHasChanges = m_generalPage->ocrLanguageSelector()->hasChanges(); ++ } ++ return m_shortcutsPage->isModified() || ocrHasChanges || KConfigDialog::hasChanged(); + } + + bool SettingsDialog::isDefault() + { +- return currentPage()->name() != i18n("Shortcuts") && m_generalPage->ocrLanguageSelector()->isDefault() && KConfigDialog::isDefault(); ++ bool ocrIsDefault = true; ++ if (OcrManager::instance()->isAvailable()) { ++ ocrIsDefault = m_generalPage->ocrLanguageSelector()->isDefault(); ++ } ++ return currentPage()->name() != i18n("Shortcuts") && ocrIsDefault && KConfigDialog::isDefault(); + } + + void SettingsDialog::updateSettings() +@@ -101,7 +109,9 @@ void SettingsDialog::updateSettings() + KConfigDialog::updateSettings(); + m_shortcutsPage->saveChanges(); + +- m_generalPage->ocrLanguageSelector()->saveSettings(); ++ if (OcrManager::instance()->isAvailable()) { ++ m_generalPage->ocrLanguageSelector()->saveSettings(); ++ } + + if (OcrManager *ocrManager = OcrManager::instance()) { + ocrManager->setConfigSyncSuspended(false); +@@ -113,7 +123,9 @@ void SettingsDialog::updateWidgets() + KConfigDialog::updateWidgets(); + m_shortcutsPage->resetChanges(); + +- m_generalPage->ocrLanguageSelector()->updateWidgets(); ++ if (OcrManager::instance()->isAvailable()) { ++ m_generalPage->ocrLanguageSelector()->updateWidgets(); ++ } + m_generalPage->refreshOcrLanguageSettings(); + + if (OcrManager *ocrManager = OcrManager::instance()) { +@@ -130,7 +142,9 @@ void SettingsDialog::updateWidgetsDefault() + KConfigDialog::updateWidgetsDefault(); + m_shortcutsPage->defaults(); + +- m_generalPage->ocrLanguageSelector()->applyDefaults(); ++ if (OcrManager::instance()->isAvailable()) { ++ m_generalPage->ocrLanguageSelector()->applyDefaults(); ++ } + m_generalPage->refreshOcrLanguageSettings(false); + } + +-- +GitLab + + +From 174b4a4a10e2c42fa28eb361cd4b6a833af60dc7 Mon Sep 17 00:00:00 2001 +From: Jhair Paris +Date: Mon, 10 Nov 2025 22:10:51 -0500 +Subject: [PATCH 4/4] Remove Tesseract language pack validation test and + simplify OCR support check + +--- + CMakeLists.txt | 24 ++---------------------- + cmake/tesseract_test.cpp | 40 ---------------------------------------- + 2 files changed, 2 insertions(+), 62 deletions(-) + delete mode 100644 cmake/tesseract_test.cpp + +diff --git a/CMakeLists.txt b/CMakeLists.txt +index ea44e71d4..2adf8a9ac 100644 +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -96,28 +96,8 @@ if(PkgConfig_FOUND) + pkg_check_modules(TESSERACT tesseract) + + if(TESSERACT_FOUND) +- # Test if Tesseract has usable language packs +- try_run( +- TESSERACT_TEST_RUN_RESULT +- TESSERACT_TEST_COMPILE_RESULT +- ${CMAKE_CURRENT_BINARY_DIR} +- ${CMAKE_CURRENT_SOURCE_DIR}/cmake/tesseract_test.cpp +- LINK_LIBRARIES ${TESSERACT_LIBRARIES} +- CMAKE_FLAGS "-DINCLUDE_DIRECTORIES=${TESSERACT_INCLUDE_DIRS}" +- COMPILE_OUTPUT_VARIABLE TESSERACT_COMPILE_OUTPUT +- RUN_OUTPUT_VARIABLE TESSERACT_RUN_OUTPUT +- ) +- +- if(TESSERACT_TEST_COMPILE_RESULT AND TESSERACT_TEST_RUN_RESULT EQUAL 0) +- message(STATUS "Tesseract OCR support enabled") +- message(STATUS "${TESSERACT_RUN_OUTPUT}") +- set(HAVE_TESSERACT_OCR TRUE) +- else() +- message(WARNING "Tesseract library found but no usable language packs detected") +- message(WARNING "${TESSERACT_RUN_OUTPUT}") +- message(WARNING "OCR functionality will be disabled. Install language data packages (e.g., tesseract-ocr-eng)") +- set(HAVE_TESSERACT_OCR FALSE) +- endif() ++ message(STATUS "Tesseract OCR support enabled") ++ set(HAVE_TESSERACT_OCR TRUE) + else() + message(STATUS "Tesseract not found - OCR functionality disabled") + set(HAVE_TESSERACT_OCR FALSE) +diff --git a/cmake/tesseract_test.cpp b/cmake/tesseract_test.cpp +deleted file mode 100644 +index 4ebae9779..000000000 +--- a/cmake/tesseract_test.cpp ++++ /dev/null +@@ -1,40 +0,0 @@ +-#include +-#include +-#include +-#include +- +-int main() +-{ +- tesseract::TessBaseAPI api; +- +- if (api.Init(nullptr, nullptr) != 0) { +- std::cerr << "Failed to initialize Tesseract" << std::endl; +- return 1; +- } +- +- std::vector languages; +- api.GetAvailableLanguagesAsVector(&languages); +- +- // Filter out 'osd' as it's not a usable language for OCR +- std::vector usableLanguages; +- for (const auto &lang : languages) { +- if (lang != "osd") { +- usableLanguages.push_back(lang); +- } +- } +- +- if (usableLanguages.empty()) { +- std::cerr << "No usable Tesseract language packs found. Install language data files (e.g., tesseract-ocr-eng)" << std::endl; +- return 1; +- } +- +- std::cout << "Found " << usableLanguages.size() << " Tesseract language pack(s): "; +- for (size_t i = 0; i < usableLanguages.size(); ++i) { +- std::cout << usableLanguages[i]; +- if (i < usableLanguages.size() - 1) +- std::cout << ", "; +- } +- std::cout << std::endl; +- +- return 0; +-} +-- +GitLab + diff --git a/roles/kde/patches/spectacle/pr493.patch b/roles/kde/patches/spectacle/pr493.patch new file mode 100644 index 0000000..d6db2f7 --- /dev/null +++ b/roles/kde/patches/spectacle/pr493.patch @@ -0,0 +1,129 @@ +From 49c615c1989d9fcfce7ed1be805538a9dca6a8a8 Mon Sep 17 00:00:00 2001 +From: Taras Oleksyn +Date: Sat, 29 Nov 2025 10:17:28 +0200 +Subject: [PATCH] Add cancel button to capture window to improve touchscreen + usability + +BUG: 490980 +--- + src/CMakeLists.txt | 1 + + src/Gui/CancelAction.qml | 11 +++++++++++ + src/Gui/CaptureOverlay.qml | 14 ++++++++++++++ + src/Gui/CaptureWindow.cpp | 5 +++++ + src/Gui/CaptureWindow.h | 1 + + src/PlasmaVersion.h | 2 +- + 6 files changed, 33 insertions(+), 1 deletion(-) + create mode 100644 src/Gui/CancelAction.qml + +diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt +index 23fc4f483..f4957bda7 100644 +--- a/src/CMakeLists.txt ++++ b/src/CMakeLists.txt +@@ -158,6 +158,7 @@ qt_target_qml_sources(spectacle + Gui/AnnotationOptionsToolBarContents.qml + Gui/AnnotationsToolBarContents.qml + Gui/ButtonGrid.qml ++ Gui/CancelAction.qml + Gui/CaptureModeButtonsColumn.qml + Gui/CaptureOptions.qml + Gui/CaptureOverlay.qml +diff --git a/src/Gui/CancelAction.qml b/src/Gui/CancelAction.qml +new file mode 100644 +index 000000000..74bf54816 +--- /dev/null ++++ b/src/Gui/CancelAction.qml +@@ -0,0 +1,11 @@ ++/* SPDX-FileCopyrightText: 2025 Noah Davis ++ * SPDX-License-Identifier: LGPL-2.0-or-later ++ */ ++ ++import QtQuick.Templates as T ++ ++T.Action { ++ icon.name: "dialog-cancel" ++ text: i18nc("@action cancel selection", "Cancel") ++ onTriggered: contextWindow.cancel() ++} +diff --git a/src/Gui/CaptureOverlay.qml b/src/Gui/CaptureOverlay.qml +index e5089934d..5c15d9939 100644 +--- a/src/Gui/CaptureOverlay.qml ++++ b/src/Gui/CaptureOverlay.qml +@@ -491,6 +491,11 @@ MouseArea { + visible: action.enabled + action: AcceptAction {} + } ++ ToolButton { ++ display: TtToolButton.TextBesideIcon ++ visible: action.enabled ++ action: CancelAction {} ++ } + ToolButton { + display: TtToolButton.IconOnly + visible: action.enabled +@@ -527,6 +532,10 @@ MouseArea { + visible: action.enabled + action: AcceptAction {} + } ++ ToolButton { ++ visible: action.enabled ++ action: CancelAction {} ++ } + ToolButton { + visible: action.enabled + action: SaveAction {} +@@ -560,6 +569,11 @@ MouseArea { + visible: action.enabled + action: RecordAction {} + } ++ ToolButton { ++ display: TtToolButton.TextBesideIcon ++ visible: action.enabled ++ action: CancelAction {} ++ } + } + } + +diff --git a/src/Gui/CaptureWindow.cpp b/src/Gui/CaptureWindow.cpp +index cb8ce97ab..c263d1b45 100644 +--- a/src/Gui/CaptureWindow.cpp ++++ b/src/Gui/CaptureWindow.cpp +@@ -129,6 +129,11 @@ bool CaptureWindow::accept() + return SelectionEditor::instance()->acceptSelection(); + } + ++void CaptureWindow::cancel() ++{ ++ SpectacleCore::instance()->cancelScreenshot(); ++} ++ + void CaptureWindow::save() + { + SelectionEditor::instance()->acceptSelection(ExportManager::Save | ExportManager::UserAction); +diff --git a/src/Gui/CaptureWindow.h b/src/Gui/CaptureWindow.h +index b5e87a834..0d50aac90 100644 +--- a/src/Gui/CaptureWindow.h ++++ b/src/Gui/CaptureWindow.h +@@ -34,6 +34,7 @@ public: + + public Q_SLOTS: + bool accept(); ++ void cancel(); + void save() override; + void saveAs() override; + void copyImage() override; +diff --git a/src/PlasmaVersion.h b/src/PlasmaVersion.h +index 08502c895..9518da62b 100644 +--- a/src/PlasmaVersion.h ++++ b/src/PlasmaVersion.h +@@ -15,7 +15,7 @@ public: + static quint32 get(); + + /** +- * Use this for plasama versions the same way you'd use QT_VERSION_CHECK() ++ * Use this for plasma versions the same way you'd use QT_VERSION_CHECK() + */ + static quint32 check(quint8 major, quint8 minor, quint8 patch); + +-- +GitLab + diff --git a/roles/kde/plasma.nix b/roles/kde/plasma.nix index d2e7ab0..9d4e8b9 100644 --- a/roles/kde/plasma.nix +++ b/roles/kde/plasma.nix @@ -1,62 +1,141 @@ -{ config, pkgs, lib, ... }: - -let - themeName = "Breeze"; - gtk3and4settings = lib.generators.toINI {} { - Settings = { - gtk-theme-name = themeName; - gtk-cursor-theme-name = "breeze_cursors"; - }; - }; - - # The configuration files that Plasma made have set the theme in gtkrc, not gtkrc-2.0 - # which is weird cause applying the theme to gtk1 but not 2 is dumb. I'll write it to - # both files just in case :3 - gtk1and2settings = '' - include "/run/current-system/sw/share/themes/${themeName}/gtk-2.0/gtkrc" - gtk-theme-name="${themeName}" - ''; -in - { - # Enable the Plasma 5 Desktop Environment. - services.xserver.displayManager.sddm.enable = true; - services.xserver.desktopManager.plasma5.enable = true; + config, + pkgs, + lib, + flakeSelf, + ... +}: let + # Set up the default kde options + balooExcludedDirs = lib.strings.intersperse "," [ + "$HOME/.cache/" + "$HOME/.config/" + "$HOME/.local/" + ]; - # GTK apps need dconf to grab the correct theme on Wayland - programs.dconf.enable = true; + baloofilerc = lib.generators.toINI {} { + General = { + # The [$e] part allows you to use environment variables + "exclude folders[$e]" = lib.strings.concatStrings balooExcludedDirs; + }; + }; - # The NixOS dconf module doesn't support creating databases - # (https://github.com/NixOS/nixpkgs/issues/54150), so I'm - # doing it manualy + # /etc/xdg is not read by plasma, so to change the default settings you need to put them in a package + plasmaDefaults = pkgs.stdenv.mkDerivation { + name = "toast-plasma-defaults"; + dontUnpack = true; + installPhase = '' + runHook preInstall - # https://help.gnome.org/admin/system-admin-guide/stable/dconf-custom-defaults.html.en - environment.etc = { - "dconf/profile/user".text = "user-db:user\nsystem-db:local"; - "dconf/db/local.d/00-defaultTheme".text = '' - [org/gnome/desktop/interface] - gtk-theme='${themeName}' - ''; - }; + set -x + mkdir -p $out/etc/xdg + echo '${baloofilerc}' > $out/etc/xdg/baloofilerc - system.activationScripts = { - dconf = { - text= '' - echo "updating system dconf database..." - ${pkgs.dconf}/bin/dconf update - touch /testfile - ''; - deps = [ "etc" ]; - }; - }; + runHook postInstall + ''; + }; +in { + # Enable the Plasma 6 Desktop Environment + services.desktopManager.plasma6.enable = true; - # Set up GTK to use Breeze instead of adwaita by default - # Theese only seem to work on X11, not wayland - environment.etc = { - "xdg/gtk-4.0/settings.ini".text = gtk3and4settings; - "xdg/gtk-3.0/settings.ini".text = gtk3and4settings; - "xdg/gtkrc-2.0".text = gtk1and2settings; - "xdg/gtkrc".text = gtk1and2settings; - }; - environment.systemPackages = [pkgs.yaru-theme]; + qt.enable = true; + + # GTK apps need dconf to grab the correct theme on Wayland + programs.dconf.enable = true; + + # Install the plasma default configs + environment.systemPackages = with pkgs.kdePackages; [ + plasmaDefaults + plasma-thunderbolt + plasma-vault + ]; + + # Allows controlling brightness on external monitors + hardware.i2c.enable = true; + + # Plasma configs should be on all users + home-manager.sharedModules = [ + { + imports = [flakeSelf.inputs.plasma-manager.homeModules.plasma-manager]; + home.packages = [ + ( + pkgs.catppuccin-kde.override { + flavour = ["mocha"]; + accents = ["mauve"]; + winDecStyles = ["classic"]; + } + ) + ]; + programs.plasma = { + enable = true; + workspace = { + clickItemTo = "select"; + cursor.theme = "Breeze_Light"; + iconTheme = "breeze-dark"; + lookAndFeel = "Catppuccin-Mocha-Mauve"; + theme = "default"; + colorScheme = "CatppuccinMochaMauve"; + }; + input = { + keyboard = { + layouts = [{layout = "es";}]; + numlockOnStartup = "off"; + }; + mice = let + settings = { + enable = true; + accelerationProfile = "none"; + }; + mice = [ + { + productId = "d030"; + vendorId = "3434"; + name = "Keychron Keychron Link "; + } + { + productId = "d03f"; + vendorId = "3434"; + name = "Keychron Keychron M6 "; + } + { + productId = "d03f"; + vendorId = "3434"; + name = "Keychron M6 Mouse"; + } + ]; + in + lib.lists.forEach mice (miceInfo: miceInfo // settings); + }; + panels = [ + { + location = "bottom"; + height = 44; + floating = true; + widgets = [ + { + kickoff = { + icon = "nix-snowflake-white"; + settings.General.switchCategoryOnHover = true; + }; + } + "org.kde.plasma.pager" + "org.kde.plasma.icontasks" + "org.kde.plasma.marginsseparator" + "org.kde.plasma.systemtray" + { + digitalClock = { + time.showSeconds = "always"; + }; + } + "org.kde.plasma.showdesktop" + ]; + } + ]; + configFile = { + "kdeglobals"."General"."AccentColor".value = null; + "auroraerc"."CatppuccinMocha-Classic"."ButtonSize".value = 0; + "plasmanotifyrc"."Notifications"."NormalAlwaysOnTop".value = true; + }; + }; + } + ]; } diff --git a/roles/kde/programs/baloo.nix b/roles/kde/programs/baloo.nix new file mode 100644 index 0000000..572376a --- /dev/null +++ b/roles/kde/programs/baloo.nix @@ -0,0 +1,16 @@ +{lib, ...}: { + home-manager.users.toast = { + programs.plasma.configFile = { + "baloofilerc"."General" = { + "exclude folders".shellExpand = true; + "exclude folders".value = with lib.strings; + concatStrings ( + intersperse "," [ + "$HOME/Documents/Repos" + "$HOME/Documents/Android" + ] + ); + }; + }; + }; +} diff --git a/roles/kde/programs/default.nix b/roles/kde/programs/default.nix new file mode 100644 index 0000000..0ac9c9f --- /dev/null +++ b/roles/kde/programs/default.nix @@ -0,0 +1,17 @@ +{pkgs, ...}: { + imports = [ + ./kate.nix + ./firefox.nix + ./skanpage.nix + # Neochat depends on olm which is unsafe now + # ./neochat.nix + ./konsole.nix + ./kwin.nix + ./baloo.nix + ./spectacle.nix + ]; + + # Enable the kde partition manager + programs.partition-manager.enable = true; + programs.steam.extraPackages = [pkgs.kdePackages.breeze]; +} diff --git a/roles/kde/programs/firefox.nix b/roles/kde/programs/firefox.nix new file mode 100644 index 0000000..41f81e2 --- /dev/null +++ b/roles/kde/programs/firefox.nix @@ -0,0 +1,35 @@ +{pkgs, ...}: { + home-manager.sharedModules = [ + { + # KDE specific firefox settings + programs.firefox = { + nativeMessagingHosts = [pkgs.kdePackages.plasma-browser-integration]; + policies = { + ExtensionSettings = { + # TODO: Install extensions the NUR instead of from AMO + "plasma-browser-integration@kde.org" = { + installation_mode = "normal_installed"; + install_url = "https://addons.mozilla.org/firefox/downloads/latest/plasma-integration/latest.xpi"; + }; + }; + Preferences = { + # Make firefox use the kde file picker + "widget.use-xdg-desktop-portal.file-picker" = { + Value = 1; + Status = "default"; + }; + /* + https://wiki.archlinux.org/title/Firefox#KDE_integration tells me to enable this, + but strangely enough doing so makes firefox ask to be set as the default browser + every time you start it up, so I'll disable it + */ + "widget.use-xdg-desktop-portal.mime-handler" = { + Value = 0; + Status = "default"; + }; + }; + }; + }; + } + ]; +} diff --git a/roles/kde/programs/kate.nix b/roles/kde/programs/kate.nix new file mode 100644 index 0000000..3e8ff36 --- /dev/null +++ b/roles/kde/programs/kate.nix @@ -0,0 +1,8 @@ +{pkgs, ...}: { + environment.systemPackages = [pkgs.kdePackages.kate]; + + # Use kwrite to open text files, and kate if I'm developing stuff + xdg.mime.defaultApplications = { + "text/plain" = "org.kde.kwrite.desktop"; + }; +} diff --git a/roles/kde/programs/konsole.nix b/roles/kde/programs/konsole.nix new file mode 100644 index 0000000..ef3b429 --- /dev/null +++ b/roles/kde/programs/konsole.nix @@ -0,0 +1,19 @@ +{flakeSelf, ...}: let + catppuccinKonsole = "${flakeSelf.inputs.catppuccin-konsole}/themes/catppuccin-mocha.colorscheme"; +in { + home-manager.users.toast = { + xdg.dataFile = { + "konsole/Catppuccin-Mocha.colorscheme".source = catppuccinKonsole; + }; + programs.konsole = { + enable = true; + defaultProfile = "Toast"; + profiles.toast = { + name = "Toast"; + colorScheme = "Catppuccin-Mocha"; + font.name = "JetBrainsMono Nerd Font"; + font.size = 10; + }; + }; + }; +} diff --git a/roles/kde/programs/kwin.nix b/roles/kde/programs/kwin.nix new file mode 100644 index 0000000..1e4f3ae --- /dev/null +++ b/roles/kde/programs/kwin.nix @@ -0,0 +1,33 @@ +{pkgs, ...}: { + environment.plasma6.excludePackages = [pkgs.kdePackages.kwin-x11]; + environment.variables = { + KWIN_WAYLAND_SUPPORT_XX_PIP_V1 = 1; + KWIN_USE_OVERLAYS = 1; + }; + home-manager.users.toast = { + programs.plasma = { + kwin = { + titlebarButtons = { + left = ["on-all-desktops" "keep-above-windows"]; + right = ["minimize" "maximize" "close"]; + }; + virtualDesktops = { + rows = 1; + number = 2; + }; + }; + configFile = { + "kwinrc" = { + "org.kde.kdecoration2"."BorderSize".value = "None"; + "TabBox"."LayoutName".value = "thumbnail_grid"; + }; + }; + shortcuts = { + "kwin" = { + "Switch One Desktop to the Left" = ["Meta+Ctrl+Left"]; + "Switch One Desktop to the Right" = ["Meta+Ctrl+Right"]; + }; + }; + }; + }; +} diff --git a/roles/kde/programs/neochat.nix b/roles/kde/programs/neochat.nix new file mode 100644 index 0000000..0e666d4 --- /dev/null +++ b/roles/kde/programs/neochat.nix @@ -0,0 +1,5 @@ +{pkgs, ...}: { + home-manager.users.toast = { + home.packages = [pkgs.neochat]; + }; +} diff --git a/roles/kde/programs/skanpage.nix b/roles/kde/programs/skanpage.nix new file mode 100644 index 0000000..114ef84 --- /dev/null +++ b/roles/kde/programs/skanpage.nix @@ -0,0 +1,12 @@ +{ + config, + lib, + pkgs, + ... +}: { + # Only install skanpage if scanning is set up + config = lib.mkIf config.hardware.sane.enable { + environment.systemPackages = [pkgs.kdePackages.skanpage]; + }; + # environment.systemPackages = if config.hardware.sane.enable == true then [ pkgs.skanpage ] else []; +} diff --git a/roles/kde/programs/spectacle.nix b/roles/kde/programs/spectacle.nix new file mode 100644 index 0000000..3dfc0e5 --- /dev/null +++ b/roles/kde/programs/spectacle.nix @@ -0,0 +1,24 @@ +{...}: { + nixpkgs.overlays = [ + ( + final: prev: { + kdePackages = prev.kdePackages.overrideScope ( + kFinal: kPrev: { + # Needed for OCR + spectacle = kPrev.spectacle.overrideAttrs (old: { + nativeBuildInputs = old.nativeBuildInputs ++ [final.pkg-config]; + buildInputs = with final; + old.buildInputs + ++ [ + tesseract + leptonica + libarchive + curl + ]; + }); + } + ); + } + ) + ]; +} diff --git a/roles/kde/sddm.nix b/roles/kde/sddm.nix new file mode 100644 index 0000000..1c296b9 --- /dev/null +++ b/roles/kde/sddm.nix @@ -0,0 +1,62 @@ +{ + lib, + pkgs, + ... +}: let + sddm-sugar-candy = pkgs.stdenv.mkDerivation { + pname = "sddm-sugar-candy"; + version = "master"; + src = pkgs.fetchgit { + url = "https://framagit.org/MarianArlt/sddm-sugar-candy.git"; + hash = "sha256-XggFVsEXLYklrfy1ElkIp9fkTw4wvXbyVkaVCZq4ZLU="; + }; + installPhase = '' + runHook preInstall + + mkdir -p $out/share/sddm/themes/sugar-candy + cp -r /build/sddm-sugar-candy/* $out/share/sddm/themes/sugar-candy + + runHook postInstall + ''; + }; + + /* + Adds a theme.conf.user file to the current sddm theme's folder, + allowing you to change it's configuration without needing to + repackage it + */ + customcfg = pkgs.stdenv.mkDerivation { + name = "sddm-theme-customizer"; + dontUnpack = true; + installPhase = let + config = lib.generators.toINI {} { + # Add the custom config here + General = { + background = "${pkgs.kdePackages.breeze}/share/wallpapers/Next/contents/images_dark/5120x2880.png"; + }; + }; + in '' + runHook preInstall + + mkdir -p $out/share/sddm/themes/breeze/ + echo "${config}" >> $out/share/sddm/themes/breeze/theme.conf.user + + runHook postInstall + ''; + }; +in { + # Enable SDDM. + services.displayManager.sddm = { + enable = true; + wayland.enable = true; + # theme = "sugar-candy"; + settings = { + General = {Numlock = "off";}; + Theme = {CursorTheme = "Breeze_Light";}; + }; + }; + + # Sugar candy doesn't seem to work on qt6 :( + # environment.systemPackages = [sddm-sugar-candy customcfg]; + environment.systemPackages = [customcfg]; +} diff --git a/roles/server/adguard.nix b/roles/server/adguard.nix new file mode 100644 index 0000000..8a57525 --- /dev/null +++ b/roles/server/adguard.nix @@ -0,0 +1,45 @@ +{ + lib, + config, + ... +}: let + domain = "adguard.everest.tailscale"; + port = 3001; +in { + services = { + adguardhome = { + enable = true; + host = "127.0.0.1"; + port = port; + settings = { + dns = { + bind_hosts = [ + ((lib.lists.last config.networking.interfaces.eno1.ipv4.addresses).address) + "100.100.0.1" + ]; + bootstrap_dns = ["9.9.9.9"]; + }; + }; + }; + + headscale.settings.dns = { + nameservers.global = lib.mkForce ["100.100.0.1"]; + extra_records = [ + { + name = domain; + type = "A"; + value = "100.100.0.1"; + } + ]; + }; + + caddy.virtualHosts.adguardhome = { + hostName = domain; + extraConfig = '' + import tailscale + reverse_proxy 127.0.0.1:${builtins.toString port} + ''; + }; + }; + programs.rust-motd.settings.service_status."AdGuard Home" = "adguardhome"; +} diff --git a/roles/server/avahi.nix b/roles/server/avahi.nix index 9302bb7..b0d3798 100755 --- a/roles/server/avahi.nix +++ b/roles/server/avahi.nix @@ -1,11 +1,9 @@ -{ config, ... }: - -{ - services.avahi = { - openFirewall = true; - publish = { - enable = true; - userServices = true; - }; - }; -} \ No newline at end of file +{...}: { + services.avahi = { + openFirewall = true; + publish = { + enable = true; + userServices = true; + }; + }; +} diff --git a/roles/server/beep.nix b/roles/server/beep.nix index b454726..f8be721 100755 --- a/roles/server/beep.nix +++ b/roles/server/beep.nix @@ -1,16 +1,16 @@ -{ config, pkgs, ... }: - -{ - # Beep as soon as possible in the initrd - boot.initrd = { - kernelModules = [ "pcspkr" ]; - extraFiles.beep.source = pkgs.beep; - postDeviceCommands = "/beep/bin/beep -f 3000 -l 50 -r 2"; - }; - /*systemd.services.startupBeep = { - description = "Beep when system started booting"; - wantedBy = [ "sysinit.target" ]; - script = "${pkgs.beep}/bin/beep -f 3000 -l 50 -r 2"; - serviceConfig = { Type = "oneshot"; }; - };*/ +{pkgs, ...}: { + # Beep as soon as possible in the initrd + boot.initrd = { + kernelModules = ["pcspkr"]; + extraFiles.beep.source = pkgs.beep; + postDeviceCommands = "/beep/bin/beep -f 3000 -l 50 -r 2"; + }; + /* + systemd.services.startupBeep = { + description = "Beep when system started booting"; + wantedBy = [ "sysinit.target" ]; + script = "${pkgs.beep}/bin/beep -f 3000 -l 50 -r 2"; + serviceConfig = { Type = "oneshot"; }; + }; + */ } diff --git a/roles/server/beets.nix b/roles/server/beets.nix new file mode 100644 index 0000000..67d76c9 --- /dev/null +++ b/roles/server/beets.nix @@ -0,0 +1,59 @@ +{...}: let + musicDir = "/srv/music"; +in { + users = { + users.music = { + isSystemUser = true; + group = "music"; + }; + # Intended for other programs to get write permission + groups.music = { + members = ["toast"]; + }; + }; + systemd.tmpfiles.settings = { + music."${musicDir}" = { + d = { + age = "-"; + user = "music"; + group = "music"; + mode = "2775"; + }; + }; + }; + + services.copyparty = { + volumes."/Music" = { + path = "/srv/music"; + access.r = "*"; + }; + }; + + home-manager.users.toast = {config, ...}: { + programs.beets = { + enable = true; + settings = { + directory = musicDir; + library = "${config.xdg.dataHome}/beets/library.db"; + + import = { + move = true; + }; + ui.color = true; + + plugins = [ + "unimported" + "fetchart" + "chroma" + "permissions" + "mbsync" + "random" + ]; + permissions = { + file = "644"; + folder = "755"; + }; + }; + }; + }; +} diff --git a/roles/server/borg.nix b/roles/server/borg.nix new file mode 100644 index 0000000..d69af2f --- /dev/null +++ b/roles/server/borg.nix @@ -0,0 +1,19 @@ +{...}: { + services.borgbackup = { + repos = { + backups = { + allowSubRepos = true; + authorizedKeys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEMtbHUcYanH/guWaKNjGr/IGa8gvI/xRTcNAI9yXhnK BorgBackup backups key" + ]; + }; + }; + }; + services.openssh.settings = { + AllowUsers = [ + "borg@*.tailscale" + "borg@192.168.1.0/24" + "borg@localhost" + ]; + }; +} diff --git a/roles/server/caddy.nix b/roles/server/caddy.nix new file mode 100644 index 0000000..14ef62a --- /dev/null +++ b/roles/server/caddy.nix @@ -0,0 +1,91 @@ +{ + config, + lib, + pkgs, + ... +}: let + manualHostname = "manual.everest.tailscale"; + downloadsHostname = "dl.everest.tailscale"; + downloadsConfig = '' + import tailscale + file_server browse + root * /srv/dl/ + ''; + script = pkgs.writeShellApplication { + name = "wait-for-tailscale-ip"; + runtimeInputs = [pkgs.iproute2]; + text = '' + # Based on https://github.com/tailscale/tailscale/issues/11504#issuecomment-2113331262 + echo Waiting for tailscale0 to get an IP adress.. + for i in {1..300}; do + if ip addr show dev tailscale0 | grep -q 'inet '; then break; fi + echo "Waiting $i/240 seconds" + sleep 1 + done + ''; + }; +in { + services.caddy = { + enable = true; + globalConfig = '' + pki { + ca local { + name "Caddy (Everest) local CA" + } + } + ''; + extraConfig = '' + (tailscale) { + tls internal + # Old tailscale IP + # bind 100.73.96.48 + bind 100.100.0.1 + } + ''; + virtualHosts = { + nixos-manual = { + hostName = manualHostname; + extraConfig = let + manual = pkgs.compressDrvWeb config.system.build.manual.manualHTML {}; + in '' + import tailscale + file_server { + precompressed zstd br gzip + } + root * ${manual}/share/doc/nixos + ''; + }; + downloads = { + hostName = downloadsHostname; + extraConfig = downloadsConfig; + }; + downloads-http = { + hostName = "http://${downloadsHostname}"; + extraConfig = downloadsConfig; + }; + }; + }; + services.headscale.settings.dns.extra_records = let + makeRecords = builtins.map (recordName: { + name = recordName; + type = "A"; + value = "100.100.0.1"; + }); + in + makeRecords [ + manualHostname + downloadsHostname + ]; + systemd = { + services.caddy.after = ["tailscaled.service"]; + # We have somewhat frequent power outages, and our ISP router takes + # ages to boot up. If I don't add a delay, caddy tries to bind to + # the tailscale interface before it's ready, making it crash too much + # in too little time + services.caddy.serviceConfig.RestartSec = lib.mkForce "120s"; + services.caddy.unitConfig.StartLimitBurst = lib.mkForce "infinity"; + services.caddy.preStart = "${script}/bin/wait-for-tailscale-ip"; + }; + programs.rust-motd.settings.service_status.Caddy = "caddy"; + networking.firewall.allowedTCPPorts = [443 80]; +} diff --git a/roles/server/copyparty.nix b/roles/server/copyparty.nix new file mode 100644 index 0000000..1cb52cd --- /dev/null +++ b/roles/server/copyparty.nix @@ -0,0 +1,37 @@ +{flakeSelf, ...}: { + nixpkgs.overlays = [flakeSelf.inputs.copyparty.overlays.default]; + services.copyparty = { + enable = true; + + settings = { + i = ["unix:770:caddy:/dev/shm/copyparty.socket"]; + hist = "/var/cache/copyparty"; + rproxy = 1; + }; + + volumes = { + "/Files" = { + path = "/srv/dl"; + access.r = "*"; + }; + }; + }; + + users.users.copyparty.extraGroups = ["caddy"]; + programs.rust-motd.settings.service_status.Copyparty = "copyparty"; + + services.headscale.settings.dns.extra_records = [ + { + name = "files.everest.tailscale"; + type = "A"; + value = "100.100.0.1"; + } + ]; + services.caddy.virtualHosts.copyparty = { + hostName = "files.everest.tailscale"; + extraConfig = '' + import tailscale + reverse_proxy unix//dev/shm/copyparty.socket + ''; + }; +} diff --git a/roles/server/ddclient.nix b/roles/server/ddclient.nix index e4520d9..a4dfd83 100755 --- a/roles/server/ddclient.nix +++ b/roles/server/ddclient.nix @@ -1,16 +1,15 @@ -{ config, ... }: +{config, ...}: { + # Set up secrets + sops.secrets.ddclientPassword = {}; -{ - # Set up secrets - age.secrets = { ddclient-passwd.file = ../../secrets/ddclient-passwd; }; - - services.ddclient = { - enable = true; - use = "web, web=dynamicdns.park-your-domain.com/getip"; - protocol = "namecheap"; - server = "dynamicdns.park-your-domain.com"; - username = "toast003.xyz"; - passwordFile = config.age.secrets.ddclient-passwd.path; - domains = [ "@" ]; - }; -} \ No newline at end of file + services.ddclient = { + enable = true; + usev4 = "webv4, webv4=https://api.ipify.org"; + usev6 = ""; + protocol = "namecheap"; + server = "dynamicdns.park-your-domain.com"; + username = "toast003.xyz"; + passwordFile = config.sops.secrets.ddclientPassword.path; + domains = ["@"]; + }; +} diff --git a/roles/server/default.nix b/roles/server/default.nix index 515d4bf..8bdf5dc 100755 --- a/roles/server/default.nix +++ b/roles/server/default.nix @@ -1,16 +1,27 @@ -{ ... }: - -{ - imports = [ - ./avahi.nix - ./nfs.nix - ./samba.nix - ./ssh.nix - ./gitea.nix - ./syncthing.nix - ./endlessh.nix - ./transmission.nix - ./ddclient.nix - ./beep.nix - ]; +{...}: { + imports = [ + ./avahi.nix + ./nfs.nix + ./samba.nix + ./ssh.nix + ./forgejo.nix + ./syncthing.nix + ./endlessh.nix + ./transmission.nix + ./ddclient.nix + ./beep.nix + ./tailscale.nix + ./headscale.nix + ./caddy.nix + ./rust_motd.nix + ./borg.nix + ./adguard.nix + ./grafana.nix + ./prometheus.nix + ./immich.nix + ./copyparty.nix + ./beets.nix + ./navidrome.nix + ./minecraft.nix + ]; } diff --git a/roles/server/endlessh.nix b/roles/server/endlessh.nix index 6646d1e..b4dcfaa 100755 --- a/roles/server/endlessh.nix +++ b/roles/server/endlessh.nix @@ -1,10 +1,21 @@ -{ config, ... }: +{config, ...}: { + # I prefer using the go implementation + services.endlessh-go = { + enable = true; + openFirewall = true; + prometheus = { + enable = true; + listenAddress = "127.0.0.1"; + }; + extraOptions = ["-geoip_supplier ip-api"]; + }; -{ - # I prefer using the go implementation - services.endlessh-go = { - enable = true; - openFirewall = true; - extraOptions = [ "-alsologtostderr" "-v=1"] ; - }; -} \ No newline at end of file + services.prometheus.scrapeConfigs = [ + { + job_name = "everest-endlessh"; + static_configs = [ + {targets = ["127.0.0.1:${builtins.toString config.services.endlessh-go.prometheus.port}"];} + ]; + } + ]; +} diff --git a/roles/server/forgejo.nix b/roles/server/forgejo.nix new file mode 100644 index 0000000..95210c4 --- /dev/null +++ b/roles/server/forgejo.nix @@ -0,0 +1,64 @@ +{ + config, + lib, + pkgs, + ... +}: { + sops.secrets = let + owner = config.services.forgejo.user; + group = config.services.forgejo.group; + in { + "forgejoHostKey/private" = { + inherit owner group; + name = "id_forgejo"; + }; + "forgejoHostKey/public" = { + inherit owner group; + name = "id_forgejo.pub"; + }; + }; + + specialisation.forgejoEnableRegistration.configuration.services.forgejo.settings.service.DISABLE_REGISTRATION = false; + services.forgejo = { + enable = true; + package = pkgs.forgejo-lts; + settings = { + service = { + DISABLE_REGISTRATION = lib.mkDefault true; + }; + server = { + OFFLINE_MODE = false; + PROTOCOL = "http+unix"; + ROOT_URL = "https://git.toast003.xyz"; + START_SSH_SERVER = true; + SSH_PORT = 4222; + SSH_SERVER_HOST_KEYS = config.sops.secrets."forgejoHostKey/private".path; + SSH_SERVER_HOST_KEY = "id_forgejo"; + }; + repository = { + ENABLE_PUSH_CREATE_USER = true; + DEFAULT_PUSH_CREATE_PRIVATE = true; + DEFAULT_BRANCH = "main"; + }; + indexer = { + REPO_INDEXER_ENABLED = true; + }; + }; + }; + + networking.firewall.allowedTCPPorts = with config; [ + services.forgejo.settings.server.SSH_PORT + ]; + + catppuccin.forgejo = { + enable = true; + }; + + # Set up caddy as the reverse proxy for Forgejo + services.caddy.virtualHosts.forgejo = { + hostName = "git.toast003.xyz"; + extraConfig = '' + reverse_proxy unix/${config.services.forgejo.settings.server.HTTP_ADDR} + ''; + }; +} diff --git a/roles/server/gitea.nix b/roles/server/gitea.nix deleted file mode 100644 index 40c8cb9..0000000 --- a/roles/server/gitea.nix +++ /dev/null @@ -1,19 +0,0 @@ -{ config, lib, ... }: - -{ - specialisation.giteaEnableRegistration.configuration.services.gitea.settings.service.DISABLE_REGISTRATION = false; - services.gitea = { - enable = true; - # TODO: Make this not be hardcoded - settings = { - server = { - #server.SSH_PORT = 69; - DISABLE_REGISTRATION = lib.mkDefault true; - ROOT_URL = "http://everest.local:3000"; - }; - }; - }; - networking.firewall = { - allowedTCPPorts = [ 3000 ]; - }; -} diff --git a/roles/server/grafana.nix b/roles/server/grafana.nix new file mode 100644 index 0000000..7563efb --- /dev/null +++ b/roles/server/grafana.nix @@ -0,0 +1,58 @@ +{config, ...}: let + domain = "monitoring.everest.tailscale"; +in { + users.users.caddy.extraGroups = ["grafana"]; + sops.secrets = let + owner = "grafana"; + group = "grafana"; + in { + "grafanaAdmin/username" = { + inherit owner group; + }; + "grafanaAdmin/password" = { + inherit owner group; + }; + }; + services = { + grafana = { + enable = true; + + provision = { + enable = true; + datasources.settings = { + apiVersion = 1; + }; + }; + + settings = { + analytics.reporting_enabled = false; + security = { + admin_user = "$__file{${config.sops.secrets."grafanaAdmin/username".path}}"; + admin_password = "$__file{${config.sops.secrets."grafanaAdmin/password".path}}"; + cookie_secure = true; + strict_transport_security = true; + content_security_policy = true; + }; + server = { + protocol = "socket"; + root_url = "https://${domain}"; + }; + }; + }; + + headscale.settings.dns.extra_records = [ + { + name = domain; + type = "A"; + value = "100.100.0.1"; + } + ]; + caddy.virtualHosts.grafana = { + hostName = domain; + extraConfig = '' + import tailscale + reverse_proxy unix/${config.services.grafana.settings.server.socket} + ''; + }; + }; +} diff --git a/roles/server/headscale.nix b/roles/server/headscale.nix new file mode 100644 index 0000000..c926c0b --- /dev/null +++ b/roles/server/headscale.nix @@ -0,0 +1,30 @@ +{lib, ...}: { + services.headscale = { + enable = true; + settings = { + server_url = "https://headscale.toast003.xyz"; + prefixes.v4 = "100.100.0.0/16"; + dns = { + base_domain = "tailscale"; + nameservers.global = ["9.9.9.9"]; + override_local_dns = true; + }; + }; + }; + services.caddy = { + virtualHosts.headscale = { + hostName = "headscale.toast003.xyz"; + extraConfig = '' + reverse_proxy localhost:8080 + ''; + }; + }; + programs.rust-motd.settings.service_status.Headscale = "headscale"; + systemd = { + services.tailscaled.after = ["headscale.service"]; + services.headscale = { + serviceConfig.RestartSec = lib.mkForce "120s"; + unitConfig.StartLimitBurst = lib.mkForce "infinity"; + }; + }; +} diff --git a/roles/server/immich.nix b/roles/server/immich.nix new file mode 100644 index 0000000..5992181 --- /dev/null +++ b/roles/server/immich.nix @@ -0,0 +1,25 @@ +{...}: { + services.immich = { + enable = true; + settings = { + server.externalDomain = "https://photos.everest.tailscale"; + server.publicUsers = false; + }; + }; + # Add a record for transmission + services.headscale.settings.dns.extra_records = [ + { + name = "photos.everest.tailscale"; + type = "A"; + value = "100.100.0.1"; + } + ]; + services.caddy.virtualHosts.immich = { + hostName = "photos.everest.tailscale"; + extraConfig = '' + import tailscale + reverse_proxy localhost:2283 + ''; + }; + programs.rust-motd.settings.service_status."Immich" = "immich-server"; +} diff --git a/roles/server/minecraft.nix b/roles/server/minecraft.nix new file mode 100644 index 0000000..9985df0 --- /dev/null +++ b/roles/server/minecraft.nix @@ -0,0 +1,99 @@ +{ + pkgs, + config, + ... +}: let + stopScript = pkgs.writeShellScript "minecraft-server-stop" '' + echo stop > ${config.systemd.sockets.minecraft-server-sf5.socketConfig.ListenFIFO} + + # Wait for the PID of the minecraft server to disappear before + # returning, so systemd doesn't attempt to SIGKILL it. + while kill -0 "$1" 2> /dev/null; do + sleep 1s + done + ''; +in { + fileSystems = { + "/var/lib/minecraft" = { + device = "/dev/disk/by-uuid/5322c217-b87b-4150-8b4c-a8fa17a899bf"; + fsType = "btrfs"; + options = ["subvol=@minecraft"]; + }; + }; + users.users.sf5 = { + isSystemUser = true; + group = "sf5"; + }; + users.groups.sf5 = {}; + systemd.tmpfiles.settings = { + music."/var/lib/minecraft/sf5" = { + d = { + age = "-"; + user = "sf5"; + group = "sf5"; + mode = "0755"; + }; + }; + }; + networking.firewall.allowedTCPPorts = [25565]; + systemd.sockets.minecraft-server-sf5 = { + bindsTo = ["minecraft-server-sf5.service"]; + socketConfig = { + ListenFIFO = "/run/minecraft-server-sf5.stdin"; + SocketMode = "0660"; + SocketUser = "sf5"; + SocketGroup = "sf5"; + RemoveOnStop = true; + FlushPending = true; + }; + }; + systemd.services.minecraft-server-sf5 = { + description = "Minecraft Server (Sky Factory 5)"; + wantedBy = ["multi-user.target"]; + requires = ["minecraft-server-sf5.socket"]; + after = [ + "network.target" + "minecraft-server-sf5.socket" + ]; + + path = [pkgs.jdk17 pkgs.bash]; + + serviceConfig = { + ExecStart = "/var/lib/minecraft/sf5/run.sh"; + ExecStop = "${stopScript} $MAINPID"; + Restart = "always"; + User = "sf5"; + WorkingDirectory = "/var/lib/minecraft/sf5"; + + StandardInput = "socket"; + StandardOutput = "journal"; + StandardError = "journal"; + + # Hardening + CapabilityBoundingSet = [""]; + DeviceAllow = [""]; + LockPersonality = true; + PrivateDevices = true; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + UMask = "0077"; + }; + }; + programs.rust-motd.settings.service_status."Minecraft (SkyFactory 5)" = "minecraft-server-sf5"; +} diff --git a/roles/server/navidrome.nix b/roles/server/navidrome.nix new file mode 100644 index 0000000..cab624f --- /dev/null +++ b/roles/server/navidrome.nix @@ -0,0 +1,31 @@ +{lib, ...}: { + services = rec { + navidrome = { + enable = true; + settings = { + BaseUrl = "https://${caddy.virtualHosts.navidrome.hostName}"; + Address = "unix:/run/navidrome/navidrome.socket"; + MusicFolder = "/srv/music"; + EnableStarRating = false; + # Better to download in copyparty / smb + EnableDownloads = false; + }; + }; + caddy.virtualHosts.navidrome = { + hostName = "music.everest.tailscale"; + extraConfig = '' + import tailscale + reverse_proxy ${lib.strings.replaceString ":" "/" navidrome.settings.Address} + ''; + }; + headscale.settings.dns.extra_records = [ + { + name = caddy.virtualHosts.navidrome.hostName; + type = "A"; + value = "100.100.0.1"; + } + ]; + }; + programs.rust-motd.settings.service_status.Navidrome = "navidrome"; + users.users.caddy.extraGroups = ["navidrome"]; +} diff --git a/roles/server/nfs.nix b/roles/server/nfs.nix index 2b83b68..37e472d 100755 --- a/roles/server/nfs.nix +++ b/roles/server/nfs.nix @@ -1,36 +1,17 @@ -{ config, lib, ... }: +{config, ...}: { + services = { + nfs.server = { + enable = true; + exports = "/srv/nfs *.tailscale(ro,fsid=root)"; + # NFSv3 uses random ports, so you need to make them static to be able to pass though the firewall + statdPort = 4000; + lockdPort = 4001; + mountdPort = 4002; + }; + }; -{ - services = { - nfs.server = { - enable = true; - exports = '' -${config.services.transmission.settings.download-dir} *.local(ro,all_squash,anonuid=${toString config.users.users.transmission.uid},anongid=${toString config.users.groups.transmission.gid}) - ''; - # NFSv3 uses random ports, so you need to make them static to be able to pass though the firewall - statdPort = 4000; - lockdPort = 4001; - mountdPort = 4002; - }; - - avahi.extraServiceFiles = { - Transmission-downloads-nfs = '' - - - - Transmission Downloads on %h (NFS) - - _nfs._tcp - 2049 - path=${config.services.transmission.settings.download-dir} - - -''; - }; - }; - - networking.firewall = { - allowedTCPPorts = [ 111 2049 4000 40001 4002 ]; - allowedUDPPorts = [ 111 2049 4000 40001 4002 ]; - }; + networking.firewall = { + allowedTCPPorts = [111 2049 4000 40001 4002]; + allowedUDPPorts = [111 2049 4000 40001 4002]; + }; } diff --git a/roles/server/prometheus.nix b/roles/server/prometheus.nix new file mode 100644 index 0000000..fb32f05 --- /dev/null +++ b/roles/server/prometheus.nix @@ -0,0 +1,33 @@ +{config, ...}: { + services.prometheus = { + enable = true; + enableReload = true; + exporters = { + node = { + enable = true; + enabledCollectors = ["systemd"]; + listenAddress = "127.0.0.1"; + port = 9002; + }; + }; + scrapeConfigs = [ + { + job_name = "everest-node"; + static_configs = [ + { + targets = ["127.0.0.1:9002"]; + } + ]; + } + ]; + }; + services.grafana.provision = { + datasources.settings.datasources = [ + { + name = "Prometheus"; + type = "prometheus"; + url = "http://127.0.0.1:${builtins.toString config.services.prometheus.port}"; + } + ]; + }; +} diff --git a/roles/server/rust_motd.nix b/roles/server/rust_motd.nix new file mode 100644 index 0000000..f8560c0 --- /dev/null +++ b/roles/server/rust_motd.nix @@ -0,0 +1,14 @@ +{...}: { + programs.rust-motd = { + enable = true; + enableMotdInSSHD = true; + settings = { + filesystems = { + Root = "/"; + "Nix Store" = "/nix"; + Boot = "/boot/efi"; + }; + last_run.last_run = true; + }; + }; +} diff --git a/roles/server/samba.nix b/roles/server/samba.nix index dcd25aa..3e551ac 100755 --- a/roles/server/samba.nix +++ b/roles/server/samba.nix @@ -1,37 +1,30 @@ -{ config, lib, ... }: +{config, ...}: { + services = { + samba = { + enable = true; + openFirewall = true; + settings = { + "Transmission downloads" = { + "path" = "${config.services.transmission.settings.download-dir}"; + "public" = true; + "writable" = false; + "force user" = "transmission"; + }; + }; + }; -{ - services = { - samba = { - enable = true; - openFirewall = true; - extraConfig = '' -map to guest = bad user -guest account = transmission -''; - shares = { - "Transmission downloads" = { - path = "${config.services.transmission.settings.download-dir}"; - "read only" = true; - public = true; - "guest only" = true; - browseable = true; - }; - }; - }; - - avahi.extraServiceFiles = { - Transmission-downloads-smb = '' - - - - SMB shares on %h - - _smb._tcp - 139 - - -''; - }; - }; + avahi.extraServiceFiles = { + everest-smb = '' + + + + SMB shares on %h + + _smb._tcp + 139 + + + ''; + }; + }; } diff --git a/roles/server/ssh.nix b/roles/server/ssh.nix index 576cd3b..33694c3 100755 --- a/roles/server/ssh.nix +++ b/roles/server/ssh.nix @@ -1,12 +1,71 @@ -{ config, ... }: - { - services.openssh = { - enable = true; - settings = { - PermitRootLogin = "no"; - PasswordAuthentication = false; - }; - startWhenNeeded = true; - }; + config, + pkgs, + lib, + ... +}: let + hostKeyPath = "/etc/ssh/everest_host_key"; + notify = + pkgs.writers.writePython3 "send-discord-login-notification" { + libraries = [pkgs.python3Packages.requests]; + } '' + import requests + import os + + if os.environ["PAM_TYPE"] != "open_session": + raise SystemExit + secretPath = "${config.sops.secrets.discordWebhook.path}" + + webhookUrl: str + + with open(secretPath) as file: + webhookUrl = file.read().strip() + + user = os.environ["PAM_USER"] + rhost = os.environ["PAM_RHOST"] + + data = { + "username": "SSH Login", + "content": user + " logged in from " + rhost + } + + result = requests.post(webhookUrl, json=data) + ''; +in { + sops.secrets = { + discordWebhook = {}; + "hostKey/public".path = "${hostKeyPath}.pub"; + "hostKey/private".path = hostKeyPath; + }; + + users.users.toast.openssh.authorizedKeys.keys = [ + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEas0RvecJpUA1rG87fHunxDb4O0Q7bQG7X8h3hZnrsE Everest access key" + ]; + + services.openssh = { + enable = true; + settings = { + UseDns = true; + PermitRootLogin = "no"; + PasswordAuthentication = false; + AllowUsers = ["toast"]; + }; + # The forgejo module is fucky so I can't set this with the nixos option + # https://github.com/NixOS/nixpkgs/issues/306205 + extraConfig = '' + AcceptEnv COLORTERM + ''; + hostKeys = [ + { + path = hostKeyPath; + type = "ed25519"; + comment = "Everest host key"; + } + ]; + startWhenNeeded = true; + }; + + security.pam.services.sshd.text = lib.mkDefault (lib.mkAfter '' + session optional pam_exec.so debug stdout ${notify} + ''); } diff --git a/roles/server/syncthing.nix b/roles/server/syncthing.nix index f16a7ba..c45a7ae 100755 --- a/roles/server/syncthing.nix +++ b/roles/server/syncthing.nix @@ -1,33 +1,74 @@ -{ config, ... }: +{config, ...}: { + services.syncthing = { + enable = true; + key = config.age.secrets.syncthingKey.path; + cert = config.age.secrets.syncthingCert.path; + guiAddress = "127.0.0.1:8384"; + settings.folders = { + "passwords" = { + path = "${config.services.syncthing.dataDir}/passwords"; + }; + "steam-201810" = { + label = "Wolfenstein The New Order Saves"; + id = "laxxf-t2wmy"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "${config.services.syncthing.dataDir}/steam-201810"; + }; + "project-diva-mods" = { + label = "Project Diva Mods"; + id = "7pscj-6egww"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "${config.services.syncthing.dataDir}/project-diva-mods"; + }; + "retroarch" = { + label = "RetroArch"; + id = "jxuou-2yjnu"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "${config.services.syncthing.dataDir}/retroarch"; + }; + "pcsx2" = { + label = "PCSX2"; + id = "qcdsp-qaaej"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "${config.services.syncthing.dataDir}/pcsx2"; + }; + "project-eden-saves" = { + label = "Project Eden saves"; + id = "xa3qx-3ax5k"; + devices = ["server" "pc" "winmax2" "steamdeck"]; + path = "${config.services.syncthing.dataDir}/project-eden-saves"; + }; + "games" = { + label = "Games"; + id = "mwzph-gf2df"; + devices = ["steamdeck" "server" "pc" "winmax2"]; + path = "${config.services.syncthing.dataDir}/games"; + }; + }; + }; + systemd.services.syncthing.serviceConfig = { + # Allow syncthing to change ownership of files + AmbientCapabilities = "CAP_CHOWN CAP_FOWNER"; + }; -{ - age.secrets = { - syncthingKey.file = ../../secrets/syncthing/key; - syncthingCert.file = ../../secrets/syncthing/cert; - }; - services.syncthing = { - enable = true; - key = config.age.secrets.syncthingKey.path; - cert = config.age.secrets.syncthingCert.path; - guiAddress = "0.0.0.0:8384"; - devices = { - "phone" = { - id = "K7KNZ5V-XREUADL-CROQXPV-6AA4H65-2VUD34Z-VQWKJ6S-LWWW4EE-XPNEZQ6"; - name = "Xiaomi Redmi Note 10 Pro"; - }; - "pc" = { - name = "Archie"; - id = "MGMYYA2-4PXGHHH-2LOVD5N-I7IYBBS-4Y4UQNK-H73S2JG-ZCK5GCN-NHTWMAR"; - addresses = [ "tcp://archie.local:22000" "tcp://192.168.0.160:22000"]; - }; - }; - folders = { - "passwords" = { - label = "KeePassXC Passwords"; - id = "rdyaq-ex659"; - path = "${config.services.syncthing.dataDir}/passwords"; - devices = [ "phone" "pc" ]; - }; - }; - }; -} \ No newline at end of file + # Add a record for syncthing + services.headscale.settings.dns.extra_records = [ + { + name = "sync.everest.tailscale"; + type = "A"; + value = "100.100.0.1"; + } + ]; + + # Set up caddy as the reverse proxy for syncthing + services.caddy.virtualHosts.syncthing = { + hostName = "sync.everest.tailscale"; + extraConfig = '' + import tailscale + reverse_proxy localhost:8384 { + header_up Host {upstream_hostport} + } + ''; + }; + programs.rust-motd.settings.service_status.Syncthing = "syncthing"; +} diff --git a/roles/server/tailscale.nix b/roles/server/tailscale.nix new file mode 100644 index 0000000..7ed054e --- /dev/null +++ b/roles/server/tailscale.nix @@ -0,0 +1,21 @@ +{pkgs, ...}: let + script = pkgs.writeShellApplication { + name = "tailscale-wait-for-ip"; + runtimeInputs = [pkgs.iproute2]; + text = '' + # Based on https://github.com/tailscale/tailscale/issues/11504#issuecomment-2113331262 + echo Waiting for tailscale0 to get an IP adress.. + for i in {1..240}; do + if ip addr show dev tailscale0 | grep -q 'inet '; then break; fi + echo "Waiting $i/240 seconds" + sleep 1 + done + ''; + }; +in { + services.tailscale = { + # This is needed for being an exit node + useRoutingFeatures = "server"; + }; + systemd.services.tailscaled.postStart = "${script}/bin/tailscale-wait-for-ip"; +} diff --git a/roles/server/transmission.nix b/roles/server/transmission.nix index 4f60c0d..ce18dbd 100755 --- a/roles/server/transmission.nix +++ b/roles/server/transmission.nix @@ -1,14 +1,62 @@ -{ config , ... }: - { - services.transmission = { - enable = true; - openFirewall = true; - openRPCPort = true; - settings = { - incomplete-dir-enabled = false; - rpc-bind-address = "0.0.0.0"; - rpc-whitelist = "127.0.0.1,192.168.0.16*"; - }; - }; -} \ No newline at end of file + config, + pkgs, + ... +}: let + transmissionUid = toString config.users.users.transmission.uid; + transmissionGid = toString config.users.groups.transmission.gid; + mountPoint = config.fileSystems."nfs_transmission".mountPoint; +in { + services.transmission = { + enable = true; + openFirewall = true; + package = pkgs.transmission_4; + settings = { + incomplete-dir-enabled = false; + rpc-bind-address = "0.0.0.0"; + rpc-host-whitelist = "transmission.everest.tailscale"; + rpc-whitelist = "127.0.0.1"; + }; + }; + + # Allow my devices to access the downloads folder though NFS + fileSystems."nfs_transmission" = { + device = config.services.transmission.settings.download-dir; + mountPoint = "/srv/nfs/transmission"; + options = ["bind"]; + }; + services.nfs.server.exports = "${mountPoint} *.tailscale(ro,all_squash,anonuid=${transmissionUid},anongid=${transmissionGid})"; + + services.avahi.extraServiceFiles = { + Transmission-downloads-nfs = '' + + + + Transmission Downloads on %h (NFS) + + _nfs._tcp + 2049 + path=${mountPoint} + + + ''; + }; + + # Add a record for transmission + services.headscale.settings.dns.extra_records = [ + { + name = "transmission.everest.tailscale"; + type = "A"; + value = "100.100.0.1"; + } + ]; + + # Set up caddy as the reverse proxy for transmission + services.caddy.virtualHosts.transmission = { + hostName = "transmission.everest.tailscale"; + extraConfig = '' + import tailscale + reverse_proxy localhost:${toString config.services.transmission.settings.rpc-port} + ''; + }; +} diff --git a/secrets/Archie/host-key-ed25519 b/secrets/Archie/host-key-ed25519 deleted file mode 100644 index ddd8e91..0000000 --- a/secrets/Archie/host-key-ed25519 +++ /dev/null @@ -1,14 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 zhSyTg v0zMwf3PyU8i5Z8cKQAM8G/egqkmPONA7twvIsTtFUU -4BlqeR6PpQrYwf7BT1UXqzaiiNwHAxsbbvX1Sk7YG7M --> ssh-ed25519 AuWU1Q m0nCQcYG0Jz8AeouayMRTPiQvZxWDbci88ouaaW1kBE -FMRP4tDLTQ8wo/9j6AaVhl4/amQAjgZDPKqmtzTwHbI --> tR-grease jXU -zPQZdJy9DQ9MUenFWBk ---- NY5Z2u04JmXtfy09gfYTziCNqdXfSXQLe3n/e7wburg - -KQoa|ɗ .hS -^aɹL)m. At}BR!7J%f#f_/=d:\[ TxȔUs(:I~-i -l!(̮SG^٢Vڗig~MDdnWqÕb7P\CαI}msU4="1.:aT-Ooy%v$iBN)s8OV(EDžtWi;nP7Q0·tR+W1BdTTOWf>6C>nT¾ -K)D81il3JPQw.w\&6j T:8E`,"a҂<dKrc2䴃<~ -h?Fc - ΣJtoD \ No newline at end of file diff --git a/secrets/Archie/host-key-ed25519-public b/secrets/Archie/host-key-ed25519-public deleted file mode 100644 index 91e279e..0000000 --- a/secrets/Archie/host-key-ed25519-public +++ /dev/null @@ -1,10 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 zhSyTg Xkk6wPQm3Sm3RuNyKhnKVz/evGJtr0UwhB7m2iuhrR4 -RMheqKeCD+Py22+xmvp3Se1z84t60+6y1Bbt7uYGxFs --> ssh-ed25519 AuWU1Q 5l5/vuIGxW+6ZzlDKjLzNCxyiW1+Kh651xpnwjfF3FQ -ZIx/zZZMPpO8zDW5JdkucIBVH1xK4KtoA7Kovw+bcOU --> 7%-grease [ wwEC MxP UF:U6Cy -Hp7t6AxdTAfm4r/LMWAt22vOYvhfHJLX4BIB7eEUfQnNAPIx43SrK8QIrAGHWbxN -hdO18C5g6xoE5HHz5uM5ASzUWC4Nws3OXwY ---- 2kwRA1NakiMhvMQgkaiEiJ93SkjTmOt77m0tO+e/p/w -^^I=*='V [$-ʲ} .=&ɭl@l5׏pIKVNCԎ I_<g.mf}O4( @ ; \ No newline at end of file diff --git a/secrets/Archie/host-key-rsa b/secrets/Archie/host-key-rsa deleted file mode 100644 index e323c7a..0000000 Binary files a/secrets/Archie/host-key-rsa and /dev/null differ diff --git a/secrets/Archie/host-key-rsa-public b/secrets/Archie/host-key-rsa-public deleted file mode 100644 index 8bb561d..0000000 Binary files a/secrets/Archie/host-key-rsa-public and /dev/null differ diff --git a/secrets/Everest/host-key-ed25519 b/secrets/Everest/host-key-ed25519 deleted file mode 100644 index 0fe034f..0000000 Binary files a/secrets/Everest/host-key-ed25519 and /dev/null differ diff --git a/secrets/Everest/host-key-ed25519-public b/secrets/Everest/host-key-ed25519-public deleted file mode 100644 index 6b23715..0000000 --- a/secrets/Everest/host-key-ed25519-public +++ /dev/null @@ -1,9 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 5qrYxA OECuD3X/YhnhNDjXFBsoq+mOQmadIQch2DhcVM2es3g -Y9tNL/OXgxSrWtvrLDHBnaWGxDoSopQAVoFwx6WiHFE --> ssh-ed25519 AuWU1Q RawOBsHa1yGd0Nn3QPaZNlh3Qy5D5TNU0VVc6t7uwmU -M0OgClrDATN23KARdN8kee/tDSolbdVQwxclOwUlCY8 --> }|y:w-grease [|V >/-D+*J -zPzM ---- st6EavuBsvVd84P9CGhxLpgckxCsYjucYvpMiNS0YVY -wav\GU.<8\<ڂ>^=„0[f,!S0z%/eo48&J?@ZJ;1/ႄ*/t{ʹ-dna8.ES$˖: \ No newline at end of file diff --git a/secrets/Everest/host-key-rsa b/secrets/Everest/host-key-rsa deleted file mode 100644 index 18618b9..0000000 Binary files a/secrets/Everest/host-key-rsa and /dev/null differ diff --git a/secrets/Everest/host-key-rsa-public b/secrets/Everest/host-key-rsa-public deleted file mode 100644 index cbfbf9f..0000000 Binary files a/secrets/Everest/host-key-rsa-public and /dev/null differ diff --git a/secrets/ddclient-passwd b/secrets/ddclient-passwd deleted file mode 100755 index fb143cb..0000000 --- a/secrets/ddclient-passwd +++ /dev/null @@ -1,8 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 5qrYxA YZag1cf+LCNznpoLx8wXN0lqaDfcxpP8Axmgt1gyiDo -DujRQ8hZtv6CyKWmOGK82jFoRkT/72Y1OmWcTb+aiVw --> tR{ rv:Iړ`%-וvMpD9, \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix deleted file mode 100755 index 5c1d5a7..0000000 --- a/secrets/secrets.nix +++ /dev/null @@ -1,18 +0,0 @@ -let - everest = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAID7GzKZIK/UAMfRjsaxWWKOBqG7sa1ttJ+Gp0zTQSBXM root@Everest"; - archie = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINuqKOfYb2lyhoQYBQbuIEyMomze872rnpxDnax8BsC5 root@Archie"; - bootsrtrap = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMKloSXSeF4dNXebd93uMuiFuXRHfxo/he4+O9SFTz1s bootstrap key"; -in -{ - "ddclient-passwd".publicKeys = [ everest ]; - "syncthing/key".publicKeys = [ everest ]; - "syncthing/cert".publicKeys = [ everest ]; - "Everest/host-key-ed25519".publicKeys = [ everest bootsrtrap ]; - "Everest/host-key-ed25519-public".publicKeys = [ everest bootsrtrap ]; - "Everest/host-key-rsa".publicKeys = [ everest bootsrtrap ]; - "Everest/host-key-rsa-public".publicKeys = [ everest bootsrtrap ]; - "Archie/host-key-ed25519".publicKeys = [ archie bootsrtrap ]; - "Archie/host-key-ed25519-public".publicKeys = [ archie bootsrtrap ]; - "Archie/host-key-rsa".publicKeys = [ archie bootsrtrap ]; - "Archie/host-key-rsa-public".publicKeys = [ archie bootsrtrap ]; -} diff --git a/secrets/syncthing/cert b/secrets/syncthing/cert deleted file mode 100755 index 9711922..0000000 Binary files a/secrets/syncthing/cert and /dev/null differ diff --git a/secrets/syncthing/key b/secrets/syncthing/key deleted file mode 100755 index e0dd47d..0000000 --- a/secrets/syncthing/key +++ /dev/null @@ -1,12 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 5qrYxA Pm14+K3RvYrvafBLUm9H885rQ8puPvRkzL+MEljElig -PGl15jcRfHEooB+vHs0OtWcKSm+zQrUMetasHHSW898 --> z(FyRLA--grease _1~=z } w0eJ^ j -6vNkqQF4u0tAzFcvi8XvLOPfQVzye3/9sMBCmtAgpw9tXAm7SBbcVb5szY6e49WX -EiP+QmD3VOU4Ygc ---- YmXm1xwJbZ/RgMqGcDsrWSBrQmgSWuBR07Ar0OQwZug -YjqVoJ -Fj\Q`' E4 g31 -q%-Wһ *{;7q=Ak4RP&aq6fiȍ>j | Y|+۸rycxϐ,ra: 0r©,iғԤ7ܼ#|vK -O 0WD8* P… la3ɫ,˴# \ No newline at end of file diff --git a/syncthing.nix b/syncthing.nix new file mode 100644 index 0000000..353964a --- /dev/null +++ b/syncthing.nix @@ -0,0 +1,28 @@ +{ + devices = { + "phone" = { + name = "Xiaomi Redmi Note 10 Pro"; + id = "K7KNZ5V-XREUADL-CROQXPV-6AA4H65-2VUD34Z-VQWKJ6S-LWWW4EE-XPNEZQ6"; + }; + "pc" = { + name = "Archie"; + id = "NJPX754-64AQNP3-7GZFIRZ-W2EDRJQ-27ORWYM-X5YXEXQ-ERRTRTQ-BSYD4AY"; + }; + "steamdeck" = { + name = "Steam Deck"; + id = "DNFEGEA-PDEVW5A-O5VBVQK-IUXI7J5-MAHCQAG-2JLEFFM-DSXB6AS-TX6ZHAN"; + }; + "server" = { + name = "Everest"; + id = "2GXFZJZ-CF56ER2-SISBGOF-VNXJIG5-GQC6ECA-NHCHAPX-677RSJT-RI5POAZ"; + }; + "surface" = { + name = "Surface Go"; + id = "HTVSF3O-AHY3TNH-BLVSEGK-HRRSMHC-H5LJWVF-NDKGM6O-ATWZALC-YXNV2Q4"; + }; + "winmax2" = { + name = "Win Max 2"; + id = "X2NILRM-ADRBQ23-AFREAZA-62GVFDF-UVMPR4L-KGHMUNY-BJ2C3CQ-RBT43QS"; + }; + }; +}