This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

pkgs: rename from 5pkgs

+4407 -4407
+1 -1
AGENTS.md
··· 7 7 This is lassulus's personal NixOS/nix-darwin configuration repository, built on clan-core for managing multiple machines. It includes: 8 8 - Multiple machine configurations in `machines/` 9 9 - Configuration modules in `2configs/` and `3modules/` 10 - - Custom packages in `5pkgs/` 10 + - Custom packages in `pkgs/` 11 11 - packages which could in theory be upstreamed to nixkgs should go into the pkgs folder, in the tools folder we add tools we write ourself which are more complex or not upstreamable 12 12 13 13 ## Key Architecture Components
+3 -3
flake.nix
··· 30 30 stockholm.inputs.buildbot-nix.follows = ""; 31 31 32 32 # Host database only (modules/retiolum/hosts.nix), on every platform. 33 - # The daemon is 5pkgs/tincr everywhere; barnacle imports tincr's own 33 + # The daemon is pkgs/tincr everywhere; barnacle imports tincr's own 34 34 # darwin module below, not kartei's retiolum shim. 35 35 kartei.url = "github:krebs/kartei"; 36 36 kartei.inputs.nixpkgs.follows = "nixpkgs"; ··· 39 39 kartei.inputs.fenix.follows = "tincr/fenix"; 40 40 41 41 # Only for darwinModules.tincr (launchd plumbing); the package comes 42 - # from 5pkgs/tincr, which pins the same fork branch. 42 + # from pkgs/tincr, which pins the same fork branch. 43 43 tincr.url = "github:Lassulus/tincr/lassulus/fixes"; 44 44 tincr.inputs.nixpkgs.follows = "nixpkgs"; 45 45 tincr.inputs.treefmt-nix.follows = "treefmt-nix"; ··· 227 227 ]; 228 228 imports = [ 229 229 ./formatter.nix 230 - ./5pkgs/flake-module.nix 230 + ./pkgs/flake-module.nix 231 231 ./keys/flake-module.nix 232 232 ./retiolum/flake-module.nix 233 233 ./skills/flake-module.nix
+1 -1
2configs/retiolum.nix
··· 18 18 # Peer keys and addresses come straight from kartei, not through 19 19 # stockholm's krebs.hosts: stockholm pins an older kartei and its host type 20 20 # still requires the legacy RSA pubkey. This is only the host database -- 21 - # the daemon stays on nixpkgs' services.tinc with 5pkgs/tincr swapped in 21 + # the daemon stays on nixpkgs' services.tinc with pkgs/tincr swapped in 22 22 # (barnacle does the same with tincr's darwin module, see 23 23 # machines/barnacle/retiolum.nix). 24 24 #
+1 -1
2configs/timeful.nix
··· 13 13 { 14 14 # timeful (formerly schej): crab.fit/when2meet-style availability polls. 15 15 # Creating a poll and painting availability work without an account; 16 - # signing in (GitHub OAuth, see 5pkgs/timeful/self-host.patch) keeps a 16 + # signing in (GitHub OAuth, see pkgs/timeful/self-host.patch) keeps a 17 17 # dashboard of your polls. 18 18 services.mongodb = { 19 19 enable = true;
-27
5pkgs/flake-module.nix
··· 1 - { ... }: 2 - { 3 - perSystem = 4 - { lib, pkgs, ... }: 5 - { 6 - packages = 7 - let 8 - # Wrapper that handles platform-incompatible packages by: 9 - # 1. Catching eval errors during callPackage 10 - # 2. Filtering based on meta.platforms 11 - platformAwareCallPackage = 12 - path: args: 13 - let 14 - result = builtins.tryEval (pkgs.callPackage path args); 15 - pkg = result.value; 16 - isSupported = result.success && lib.meta.availableOn pkgs.stdenv.hostPlatform pkg; 17 - in 18 - if isSupported then pkg else null; 19 - 20 - allPackages = lib.packagesFromDirectoryRecursive { 21 - callPackage = platformAwareCallPackage; 22 - directory = ./.; 23 - }; 24 - in 25 - lib.filterAttrs (name: pkg: name != "flake-module" && pkg != null) allPackages; 26 - }; 27 - }
+27
pkgs/flake-module.nix
··· 1 + { ... }: 2 + { 3 + perSystem = 4 + { lib, pkgs, ... }: 5 + { 6 + packages = 7 + let 8 + # Wrapper that handles platform-incompatible packages by: 9 + # 1. Catching eval errors during callPackage 10 + # 2. Filtering based on meta.platforms 11 + platformAwareCallPackage = 12 + path: args: 13 + let 14 + result = builtins.tryEval (pkgs.callPackage path args); 15 + pkg = result.value; 16 + isSupported = result.success && lib.meta.availableOn pkgs.stdenv.hostPlatform pkg; 17 + in 18 + if isSupported then pkg else null; 19 + 20 + allPackages = lib.packagesFromDirectoryRecursive { 21 + callPackage = platformAwareCallPackage; 22 + directory = ./.; 23 + }; 24 + in 25 + lib.filterAttrs (name: pkg: name != "flake-module" && pkg != null) allPackages; 26 + }; 27 + }
+1 -1
2configs/services/archiver-bot.nix
··· 52 52 export RADARR_API_KEY="$(${pkgs.libxml2}/bin/xmllint --xpath 'string(//ApiKey)' "$CREDENTIALS_DIRECTORY/radarr-config")" 53 53 export SONARR_API_KEY="$(${pkgs.libxml2}/bin/xmllint --xpath 'string(//ApiKey)' "$CREDENTIALS_DIRECTORY/sonarr-config")" 54 54 export JELLYFIN_API_KEY="$(cat "$CREDENTIALS_DIRECTORY/jellyfin-api-key")" 55 - exec ${pkgs.callPackage ../../5pkgs/archiver-bot/package.nix { }}/bin/archiver-bot 55 + exec ${pkgs.callPackage ../../pkgs/archiver-bot/package.nix { }}/bin/archiver-bot 56 56 ''; 57 57 58 58 serviceConfig = {
-38
5pkgs/age-plugin-gpg/package.nix
··· 1 - { 2 - lib, 3 - rustPlatform, 4 - fetchFromGitHub, 5 - pkg-config, 6 - nettle, 7 - }: 8 - rustPlatform.buildRustPackage (finalAttrs: { 9 - pname = "age-plugin-gpg"; 10 - version = "0.1.0"; 11 - 12 - src = fetchFromGitHub { 13 - owner = "CertainLach"; 14 - repo = "age-plugin-gpg"; 15 - rev = "v${finalAttrs.version}"; 16 - hash = "sha256-HfCl+YAjKPNFtLAHjNyjlHhRxD85qZLWsfKzqORtj5U="; 17 - }; 18 - 19 - cargoLock = { 20 - lockFile = "${finalAttrs.src}/Cargo.lock"; 21 - }; 22 - 23 - nativeBuildInputs = [ 24 - pkg-config 25 - rustPlatform.bindgenHook 26 - ]; 27 - 28 - buildInputs = [ nettle ]; 29 - 30 - meta = { 31 - description = "Age plugin utilizing gpg-agent & keygrips as recipients/identities"; 32 - homepage = "https://github.com/CertainLach/age-plugin-gpg"; 33 - license = lib.licenses.mit; 34 - mainProgram = "age-plugin-gpg"; 35 - platforms = lib.platforms.unix; 36 - maintainers = [ ]; 37 - }; 38 - })
-24
5pkgs/archiver-bot/package.nix
··· 1 - { 2 - python3Packages, 3 - lib, 4 - }: 5 - python3Packages.buildPythonApplication { 6 - pname = "archiver-bot"; 7 - version = "0.1.0"; 8 - pyproject = true; 9 - 10 - src = ./.; 11 - 12 - build-system = [ python3Packages.setuptools ]; 13 - 14 - dependencies = with python3Packages; [ 15 - (matrix-nio.override { withVodozemac = true; }) 16 - aiohttp 17 - ]; 18 - 19 - meta = { 20 - description = "Matrix bot that requests movies/shows via Jellyseerr when IMDb links are posted"; 21 - mainProgram = "archiver-bot"; 22 - platforms = lib.platforms.all; 23 - }; 24 - }
-14
5pkgs/archiver-bot/pyproject.toml
··· 1 - [build-system] 2 - requires = ["setuptools"] 3 - build-backend = "setuptools.build_meta" 4 - 5 - [project] 6 - name = "archiver-bot" 7 - version = "0.1.0" 8 - dependencies = [ 9 - "matrix-nio", 10 - "aiohttp", 11 - ] 12 - 13 - [project.scripts] 14 - archiver-bot = "archiver_bot:main"
-19
5pkgs/bank/package.nix
··· 1 - { 2 - writers, 3 - coreutils, 4 - pass, 5 - hledger, 6 - diffutils, 7 - }: 8 - 9 - writers.writeDashBin "bank" '' 10 - tmp=$(mktemp) 11 - ${pass}/bin/pass show hledger > $tmp 12 - ${hledger}/bin/hledger --file=$tmp "$@" 13 - ${pass}/bin/pass show hledger | if ${diffutils}/bin/diff $tmp -; then 14 - exit 0 15 - else 16 - ${coreutils}/bin/cat $tmp | ${pass}/bin/pass insert -m hledger 17 - fi 18 - ${coreutils}/bin/rm $tmp 19 - ''
-90
5pkgs/dectalk/package.nix
··· 1 - { 2 - lib, 3 - stdenv, 4 - fetchFromGitHub, 5 - autoconf, 6 - automake, 7 - pkg-config, 8 - libpulseaudio, 9 - alsa-lib, 10 - }: 11 - 12 - stdenv.mkDerivation { 13 - pname = "dectalk"; 14 - version = "0-unstable-2025-10-27"; 15 - 16 - src = fetchFromGitHub { 17 - owner = "dectalk"; 18 - repo = "dectalk"; 19 - rev = "c3c1e4ae4117f951d8d7f9ef2848408ec79047fd"; 20 - hash = "sha256-28d8/LLxtkfjBLRz8jH6xGf16koxMZqm2h67bAey4qg="; 21 - }; 22 - 23 - nativeBuildInputs = [ 24 - autoconf 25 - automake 26 - pkg-config 27 - ]; 28 - 29 - buildInputs = [ 30 - libpulseaudio 31 - alsa-lib 32 - ]; 33 - 34 - postPatch = '' 35 - # Replace git version detection with static version since .git is not available 36 - sed -i 's/m4_esyscmd_s(echo $(git describe --always)$(git status --porcelain | awk .*))/c3c1e4a/' src/configure.ac 37 - ''; 38 - 39 - configurePhase = '' 40 - runHook preConfigure 41 - cd src 42 - ./autogen.sh 43 - ./configure --prefix=$out 44 - cd .. 45 - runHook postConfigure 46 - ''; 47 - 48 - buildPhase = '' 49 - runHook preBuild 50 - make -C src -j$NIX_BUILD_CORES 51 - runHook postBuild 52 - ''; 53 - 54 - installPhase = '' 55 - runHook preInstall 56 - mkdir -p $out/bin $out/lib $out/dic 57 - 58 - # Libraries 59 - cp dist/lib/*.so $out/lib/ 60 - 61 - # Main binaries 62 - for bin in say dtmemory aclock; do 63 - if [ -f "dist/$bin" ]; then 64 - cp "dist/$bin" $out/bin/ 65 - fi 66 - done 67 - 68 - # Tools 69 - if [ -d dist/tools ]; then 70 - cp dist/tools/* $out/bin/ 71 - fi 72 - 73 - # Dictionaries 74 - cp dist/dic/* $out/dic/ 75 - 76 - # DECtalk config — must be at $out/DECtalk.conf (compiled-in DECTALK_INSTALL_PREFIX) 77 - cp dist/DECtalk.conf $out/DECtalk.conf 78 - 79 - runHook postInstall 80 - ''; 81 - 82 - meta = { 83 - description = "Modern builds of the DECtalk text-to-speech engine"; 84 - homepage = "https://github.com/dectalk/dectalk"; 85 - # license = lib.licenses.unfree; 86 - license = lib.licenses.free; 87 - platforms = lib.platforms.linux; 88 - mainProgram = "say"; 89 - }; 90 - }
-170
5pkgs/elinks/package.nix
··· 1 - # Replacement for nixpkgs's `elinks`, switched to the meson build from 2 - # upstream nixpkgs PR #515347 (https://github.com/NixOS/nixpkgs/pull/515347). 3 - # 4 - # nixpkgs's autotools build of elinks 0.19.1 is broken against gettext 5 - # 0.23.1 (autopoint refuses `AM_GNU_GETTEXT` without an `external` 6 - # argument; further, autoheader then fails on `HAVE_ALLOCA`). The PR 7 - # sidesteps autotools entirely. We carry it locally until it lands. 8 - # 9 - # The PR also moves a few entries from `buildInputs` to 10 - # `propagatedBuildInputs` on libcss and libdom so they're picked up 11 - # transitively. Rather than overlaying those packages globally, we 12 - # tweak them inline here (only this elinks build sees the change). 13 - { 14 - lib, 15 - stdenv, 16 - fetchFromGitHub, 17 - ncurses, 18 - bzip2, 19 - zlib, 20 - brotli, 21 - zstd, 22 - xz, 23 - openssl, 24 - meson, 25 - ninja, 26 - gettext, 27 - python3, 28 - perl, 29 - pkg-config, 30 - xmlto, 31 - docbook_xml_dtd_42, 32 - gpm, 33 - libidn2, 34 - tre, 35 - expat, 36 - lua, 37 - curl, 38 - libcss, 39 - libdom, 40 - libparserutils, 41 - libwapcaplet, 42 - libhubbub, 43 - enableGuile ? false, 44 - guile ? null, 45 - enablePython ? false, 46 - python ? null, 47 - enablePerl ? (!stdenv.hostPlatform.isDarwin) && (stdenv.hostPlatform == stdenv.buildPlatform), 48 - }: 49 - 50 - let 51 - # libcss with libparserutils/libwapcaplet propagated, so meson's 52 - # pkg-config lookup finds them when elinks links against libcss. 53 - libcss' = libcss.overrideAttrs (old: { 54 - buildInputs = lib.filter (d: d != libparserutils && d != libwapcaplet) (old.buildInputs or [ ]); 55 - propagatedBuildInputs = (old.propagatedBuildInputs or [ ]) ++ [ 56 - libparserutils 57 - libwapcaplet 58 - ]; 59 - }); 60 - 61 - # libdom: libhubbub/libwapcaplet propagated, for the same reason. 62 - libdom' = libdom.overrideAttrs (old: { 63 - buildInputs = lib.filter (d: d != libhubbub && d != libwapcaplet) (old.buildInputs or [ ]); 64 - propagatedBuildInputs = (old.propagatedBuildInputs or [ ]) ++ [ 65 - libhubbub 66 - libwapcaplet 67 - ]; 68 - }); 69 - in 70 - stdenv.mkDerivation (finalAttrs: { 71 - pname = "elinks"; 72 - version = "0.19.1"; 73 - 74 - src = fetchFromGitHub { 75 - owner = "rkd77"; 76 - repo = "elinks"; 77 - rev = "v${finalAttrs.version}"; 78 - hash = "sha256-aQ+q2I6uTVv5kpKBaGJ1xiE/9vv9T7JI05VX/ROkAqA="; 79 - }; 80 - 81 - outputs = [ 82 - "out" 83 - "man" 84 - "doc" 85 - ]; 86 - 87 - buildInputs = [ 88 - ncurses 89 - bzip2 90 - zlib 91 - brotli 92 - zstd 93 - xz 94 - openssl 95 - libidn2 96 - tre 97 - expat 98 - lua 99 - curl 100 - libcss' 101 - libdom' 102 - ] 103 - ++ lib.optional stdenv.hostPlatform.isDarwin gettext 104 - ++ lib.optional enableGuile guile 105 - ++ lib.optional enablePython python 106 - ++ lib.optional enablePerl perl; 107 - 108 - nativeBuildInputs = [ 109 - meson 110 - ninja 111 - gettext 112 - perl 113 - python3 114 - pkg-config 115 - xmlto 116 - ]; 117 - 118 - env = 119 - lib.optionalAttrs stdenv.hostPlatform.isLinux { 120 - C_INCLUDE_PATH = "${lib.getInclude gpm}/include"; 121 - LIBRARY_PATH = "${lib.getLib gpm}/lib"; 122 - } 123 - // lib.optionalAttrs stdenv.hostPlatform.isDarwin { 124 - LDFLAGS = "-liconv"; 125 - }; 126 - 127 - strictDeps = true; 128 - __structuredAttrs = true; 129 - 130 - mesonFlags = 131 - (map (f: lib.mesonBool f true) [ 132 - "finger" 133 - "html-highlight" 134 - "gopher" 135 - "gemini" 136 - "cgi" 137 - "nntp" 138 - "256-colors" 139 - "true-color" 140 - "brotli" 141 - "lzma" 142 - "terminfo" 143 - "reproducible" 144 - ]) 145 - ++ [ 146 - (lib.mesonOption "luapkg" "lua") 147 - (lib.mesonBool "gpm" stdenv.hostPlatform.isLinux) 148 - (lib.mesonBool "guile" enableGuile) 149 - (lib.mesonBool "python" enablePython) 150 - (lib.mesonBool "perl" enablePerl) 151 - ]; 152 - 153 - postPatch = '' 154 - patchShebangs doc/tools 155 - substituteInPlace doc/tools/asciidoc/docbook.conf \ 156 - --replace-fail "http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd" "${docbook_xml_dtd_42}/xml/dtd/docbook/docbookx.dtd" 157 - ''; 158 - 159 - preConfigure = '' 160 - mesonFlags+=("-Dsource-date-epoch=$SOURCE_DATE_EPOCH") 161 - ''; 162 - 163 - meta = { 164 - description = "Full-featured text-mode web browser (meson build, PR #515347)"; 165 - mainProgram = "elinks"; 166 - homepage = "https://github.com/rkd77/elinks"; 167 - license = lib.licenses.gpl2Only; 168 - platforms = with lib.platforms; linux ++ darwin; 169 - }; 170 - })
-356
5pkgs/git-mcp/git-mcp.py
··· 1 - #!/usr/bin/env python3 2 - """ 3 - Git MCP Server - Advanced Git operations for AI assistants 4 - """ 5 - 6 - import asyncio 7 - from typing import Dict, List, Any 8 - import os 9 - 10 - from mcp.server import Server 11 - from mcp.server.stdio import stdio_server 12 - from mcp.types import Tool, TextContent 13 - 14 - 15 - class GitMCP: 16 - def __init__(self): 17 - self.repo_path = os.getcwd() 18 - 19 - async def run_git_command(self, cmd: List[str]) -> Dict[str, Any]: 20 - """Run a git command and return the result""" 21 - try: 22 - result = await asyncio.create_subprocess_exec( 23 - "git", 24 - *cmd, 25 - stdout=asyncio.subprocess.PIPE, 26 - stderr=asyncio.subprocess.PIPE, 27 - cwd=self.repo_path, 28 - ) 29 - stdout, stderr = await result.communicate() 30 - 31 - if result.returncode == 0: 32 - return {"output": stdout.decode("utf-8")} 33 - else: 34 - return {"error": stderr.decode("utf-8")} 35 - except Exception as e: 36 - return {"error": str(e)} 37 - 38 - async def git_status(self, args: Dict[str, Any]) -> str: 39 - """Get detailed git status""" 40 - cmd = ["status", "--porcelain"] 41 - if args.get("untracked", True): 42 - cmd.append("--untracked-files=all") 43 - if args.get("ignored"): 44 - cmd.append("--ignored") 45 - 46 - result = await self.run_git_command(cmd) 47 - if "error" in result: 48 - return f"Error: {result['error']}" 49 - 50 - return result["output"] 51 - 52 - async def git_diff(self, args: Dict[str, Any]) -> str: 53 - """Get diff with various options""" 54 - cmd = ["diff"] 55 - if args.get("staged"): 56 - cmd.append("--staged") 57 - if args.get("commit"): 58 - cmd.append(args["commit"]) 59 - if args.get("context"): 60 - cmd.extend(["-U", str(args["context"])]) 61 - if args.get("files"): 62 - cmd.extend(["--"] + args["files"]) 63 - 64 - result = await self.run_git_command(cmd) 65 - if "error" in result: 66 - return f"Error: {result['error']}" 67 - 68 - return result["output"] 69 - 70 - async def git_log(self, args: Dict[str, Any]) -> str: 71 - """Get commit history""" 72 - cmd = ["log", "--oneline"] 73 - if args.get("limit"): 74 - cmd.extend(["-n", str(args["limit"])]) 75 - if args.get("author"): 76 - cmd.extend(["--author", args["author"]]) 77 - if args.get("since"): 78 - cmd.extend(["--since", args["since"]]) 79 - if args.get("grep"): 80 - cmd.extend(["--grep", args["grep"]]) 81 - if args.get("files"): 82 - cmd.extend(["--"] + args["files"]) 83 - 84 - result = await self.run_git_command(cmd) 85 - if "error" in result: 86 - return f"Error: {result['error']}" 87 - 88 - return result["output"] 89 - 90 - async def git_branch(self, args: Dict[str, Any]) -> str: 91 - """Manage branches""" 92 - action = args.get("action", "list") 93 - 94 - if action == "list": 95 - cmd = ["branch", "-v"] 96 - if args.get("remote"): 97 - cmd.append("-r") 98 - elif action == "create": 99 - if not args.get("name"): 100 - return "Error: Branch name required" 101 - cmd = ["checkout", "-b", args["name"]] 102 - elif action == "delete": 103 - if not args.get("name"): 104 - return "Error: Branch name required" 105 - cmd = ["branch", "-d", args["name"]] 106 - else: 107 - return f"Error: Unknown action: {action}" 108 - 109 - result = await self.run_git_command(cmd) 110 - if "error" in result: 111 - return f"Error: {result['error']}" 112 - 113 - return result["output"] 114 - 115 - async def git_add(self, args: Dict[str, Any]) -> str: 116 - """Stage files""" 117 - files = args.get("files", []) 118 - if not files: 119 - return "Error: No files specified" 120 - 121 - cmd = ["add"] + files 122 - result = await self.run_git_command(cmd) 123 - if "error" in result: 124 - return f"Error: {result['error']}" 125 - 126 - return "Files staged successfully" 127 - 128 - async def git_commit(self, args: Dict[str, Any]) -> str: 129 - """Create a commit""" 130 - message = args.get("message") 131 - if not message: 132 - return "Error: Commit message required" 133 - 134 - cmd = ["commit", "-m", message] 135 - result = await self.run_git_command(cmd) 136 - if "error" in result: 137 - return f"Error: {result['error']}" 138 - 139 - return result["output"] 140 - 141 - async def git_push(self, args: Dict[str, Any]) -> str: 142 - """Push commits""" 143 - cmd = ["push"] 144 - if args.get("remote"): 145 - cmd.append(args["remote"]) 146 - if args.get("branch"): 147 - cmd.append(args["branch"]) 148 - 149 - result = await self.run_git_command(cmd) 150 - if "error" in result: 151 - return f"Error: {result['error']}" 152 - 153 - return result["output"] 154 - 155 - async def git_pull(self, args: Dict[str, Any]) -> str: 156 - """Pull commits""" 157 - cmd = ["pull"] 158 - if args.get("remote"): 159 - cmd.append(args["remote"]) 160 - if args.get("branch"): 161 - cmd.append(args["branch"]) 162 - 163 - result = await self.run_git_command(cmd) 164 - if "error" in result: 165 - return f"Error: {result['error']}" 166 - 167 - return result["output"] 168 - 169 - 170 - async def main(): 171 - git_mcp = GitMCP() 172 - 173 - # Create the MCP server 174 - server = Server("git-mcp") 175 - 176 - # Register tools 177 - @server.list_tools() 178 - async def list_tools() -> List[Tool]: 179 - return [ 180 - Tool( 181 - name="git_status", 182 - description="Get git repository status", 183 - inputSchema={ 184 - "type": "object", 185 - "properties": { 186 - "untracked": { 187 - "type": "boolean", 188 - "description": "Include untracked files", 189 - }, 190 - "ignored": { 191 - "type": "boolean", 192 - "description": "Include ignored files", 193 - }, 194 - }, 195 - "additionalProperties": False, 196 - }, 197 - ), 198 - Tool( 199 - name="git_diff", 200 - description="Get git diff", 201 - inputSchema={ 202 - "type": "object", 203 - "properties": { 204 - "staged": { 205 - "type": "boolean", 206 - "description": "Show staged changes", 207 - }, 208 - "commit": { 209 - "type": "string", 210 - "description": "Show diff for specific commit", 211 - }, 212 - "files": { 213 - "type": "array", 214 - "items": {"type": "string"}, 215 - "description": "Specific files to diff", 216 - }, 217 - "context": { 218 - "type": "number", 219 - "description": "Lines of context", 220 - }, 221 - }, 222 - "additionalProperties": False, 223 - }, 224 - ), 225 - Tool( 226 - name="git_log", 227 - description="Get commit history", 228 - inputSchema={ 229 - "type": "object", 230 - "properties": { 231 - "limit": {"type": "number", "description": "Number of commits"}, 232 - "author": {"type": "string", "description": "Filter by author"}, 233 - "since": {"type": "string", "description": "Since date"}, 234 - "grep": { 235 - "type": "string", 236 - "description": "Search commit messages", 237 - }, 238 - "files": { 239 - "type": "array", 240 - "items": {"type": "string"}, 241 - "description": "Show commits affecting files", 242 - }, 243 - }, 244 - "additionalProperties": False, 245 - }, 246 - ), 247 - Tool( 248 - name="git_branch", 249 - description="Manage branches", 250 - inputSchema={ 251 - "type": "object", 252 - "properties": { 253 - "action": { 254 - "type": "string", 255 - "enum": ["list", "create", "delete"], 256 - "description": "Action to perform", 257 - }, 258 - "name": {"type": "string", "description": "Branch name"}, 259 - "remote": { 260 - "type": "boolean", 261 - "description": "Include remote branches", 262 - }, 263 - }, 264 - "additionalProperties": False, 265 - }, 266 - ), 267 - Tool( 268 - name="git_add", 269 - description="Stage files for commit", 270 - inputSchema={ 271 - "type": "object", 272 - "properties": { 273 - "files": { 274 - "type": "array", 275 - "items": {"type": "string"}, 276 - "description": "Files to stage", 277 - } 278 - }, 279 - "required": ["files"], 280 - "additionalProperties": False, 281 - }, 282 - ), 283 - Tool( 284 - name="git_commit", 285 - description="Create a commit", 286 - inputSchema={ 287 - "type": "object", 288 - "properties": { 289 - "message": {"type": "string", "description": "Commit message"} 290 - }, 291 - "required": ["message"], 292 - "additionalProperties": False, 293 - }, 294 - ), 295 - Tool( 296 - name="git_push", 297 - description="Push commits to remote", 298 - inputSchema={ 299 - "type": "object", 300 - "properties": { 301 - "remote": {"type": "string", "description": "Remote name"}, 302 - "branch": {"type": "string", "description": "Branch name"}, 303 - }, 304 - "additionalProperties": False, 305 - }, 306 - ), 307 - Tool( 308 - name="git_pull", 309 - description="Pull commits from remote", 310 - inputSchema={ 311 - "type": "object", 312 - "properties": { 313 - "remote": {"type": "string", "description": "Remote name"}, 314 - "branch": {"type": "string", "description": "Branch name"}, 315 - }, 316 - "additionalProperties": False, 317 - }, 318 - ), 319 - ] 320 - 321 - @server.call_tool() 322 - async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: 323 - """Handle tool calls""" 324 - try: 325 - if name == "git_status": 326 - result = await git_mcp.git_status(arguments) 327 - elif name == "git_diff": 328 - result = await git_mcp.git_diff(arguments) 329 - elif name == "git_log": 330 - result = await git_mcp.git_log(arguments) 331 - elif name == "git_branch": 332 - result = await git_mcp.git_branch(arguments) 333 - elif name == "git_add": 334 - result = await git_mcp.git_add(arguments) 335 - elif name == "git_commit": 336 - result = await git_mcp.git_commit(arguments) 337 - elif name == "git_push": 338 - result = await git_mcp.git_push(arguments) 339 - elif name == "git_pull": 340 - result = await git_mcp.git_pull(arguments) 341 - else: 342 - result = f"Unknown tool: {name}" 343 - 344 - return [TextContent(type="text", text=result)] 345 - except Exception as e: 346 - return [TextContent(type="text", text=f"Error: {str(e)}")] 347 - 348 - # Run the server 349 - async with stdio_server() as (read_stream, write_stream): 350 - await server.run( 351 - read_stream, write_stream, server.create_initialization_options() 352 - ) 353 - 354 - 355 - if __name__ == "__main__": 356 - asyncio.run(main())
-18
5pkgs/git-mcp/package.nix
··· 1 - { pkgs, ... }: 2 - let 3 - python = pkgs.python3.withPackages ( 4 - ps: with ps; [ 5 - mcp 6 - ] 7 - ); 8 - in 9 - pkgs.writeShellApplication { 10 - name = "git-mcp"; 11 - runtimeInputs = with pkgs; [ 12 - git 13 - python 14 - ]; 15 - text = '' 16 - ${python}/bin/python ${./git-mcp.py} "$@" 17 - ''; 18 - }
-81
5pkgs/gomuks/package.nix
··· 1 - { 2 - lib, 3 - fetchFromGitHub, 4 - fetchNpmDeps, 5 - buildGoModule, 6 - nodejs, 7 - npmHooks, 8 - }: 9 - 10 - # gomuks TUI with E2EE via pure-Go goolm (no insecure libolm needed) 11 - # Built from the new gomuks codebase which includes cmd/gomuks-terminal 12 - buildGoModule ( 13 - finalAttrs: 14 - let 15 - rev = "82abf26775d59c642fa7ea5274cf8631cd6942c6"; 16 - in 17 - { 18 - pname = "gomuks"; 19 - version = "0.2601.0"; 20 - 21 - proxyVendor = true; 22 - vendorHash = "sha256-M493helHfjHyJCqHxyaC2wkhtsFNA937crdnfCWj9IQ="; 23 - 24 - src = fetchFromGitHub { 25 - owner = "gomuks"; 26 - repo = "gomuks"; 27 - hash = "sha256-0AqA1Fl6xIotmF1hYfEm2FEEiSBzBp7RDnaJjTBeTwU="; 28 - inherit rev; 29 - }; 30 - 31 - nativeBuildInputs = [ 32 - nodejs 33 - npmHooks.npmConfigHook 34 - ]; 35 - 36 - env = { 37 - npmRoot = "web"; 38 - npmDeps = fetchNpmDeps { 39 - src = "${finalAttrs.src}/web"; 40 - hash = "sha256-SUxE4SZOZI+GgLTecRf5rqYRv3koV85eCZ1TFKO4gTI="; 41 - }; 42 - }; 43 - 44 - postPatch = '' 45 - substituteInPlace ./web/build-wasm.sh \ 46 - --replace-fail 'go.mau.fi/gomuks/version.Tag=$(git describe --exact-match --tags 2>/dev/null)' "go.mau.fi/gomuks/version.Tag=v${finalAttrs.version}" \ 47 - --replace-fail 'go.mau.fi/gomuks/version.Commit=$(git rev-parse HEAD)' "go.mau.fi/gomuks/version.Commit=${rev}" 48 - ''; 49 - 50 - doCheck = false; 51 - 52 - tags = [ "goolm" ]; 53 - 54 - ldflags = [ 55 - "-X 'go.mau.fi/gomuks/version.Tag=v${finalAttrs.version}'" 56 - "-X 'go.mau.fi/gomuks/version.Commit=${rev}'" 57 - ]; 58 - 59 - subPackages = [ 60 - "cmd/gomuks" 61 - "cmd/gomuks-terminal" 62 - ]; 63 - 64 - preBuild = '' 65 - CGO_ENABLED=0 go generate ./web 66 - ''; 67 - 68 - postInstall = '' 69 - mv $out/bin/gomuks $out/bin/gomuks-server 70 - mv $out/bin/gomuks-terminal $out/bin/gomuks-tui 71 - ''; 72 - 73 - meta = { 74 - mainProgram = "gomuks"; 75 - description = "Terminal Matrix client with E2EE via goolm"; 76 - homepage = "https://github.com/tulir/gomuks"; 77 - license = lib.licenses.agpl3Only; 78 - platforms = lib.platforms.unix; 79 - }; 80 - } 81 - )
-54
5pkgs/kokoro/package.nix
··· 1 - { 2 - python3, 3 - fetchurl, 4 - fetchFromGitHub, 5 - }: 6 - let 7 - # Create a python environment with our overrides 8 - python = python3.override { 9 - self = python; 10 - packageOverrides = final: prev: { 11 - # Override kokoro with a more recent version and stdout support 12 - kokoro = prev.kokoro.overridePythonAttrs (old: { 13 - version = "0-unstable-2025-08-06"; 14 - src = fetchFromGitHub { 15 - owner = "hexgrad"; 16 - repo = "kokoro"; 17 - rev = "dfb907a02bba8152ca444717ca5d78747ccb4bec"; 18 - hash = "sha256-GJlc3+RCeYaAvojFFjK22nitDTWFWp6dAPJakw+//j8="; 19 - }; 20 - patches = (old.patches or [ ]) ++ [ 21 - ./stdout-support.patch 22 - ]; 23 - }); 24 - # Spacy model - downloaded and packaged as a python package 25 - spacy-en-core-web-sm = final.buildPythonPackage rec { 26 - pname = "en_core_web_sm"; 27 - version = "3.8.0"; 28 - format = "wheel"; 29 - 30 - src = fetchurl { 31 - url = "https://github.com/explosion/spacy-models/releases/download/${pname}-${version}/${pname}-${version}-py3-none-any.whl"; 32 - hash = "sha256-GTJCnbcn1L/z3u1rNM/AXfF3lPSlLusmz4ko98Gg+4U="; 33 - }; 34 - 35 - propagatedBuildInputs = [ final.spacy ]; 36 - 37 - pythonImportsCheck = [ "en_core_web_sm" ]; 38 - }; 39 - 40 - # Create a patched misaki that doesn't try to download spacy models 41 - misaki = prev.misaki.overridePythonAttrs (old: { 42 - postPatch = (old.postPatch or "") + '' 43 - # Patch out the spacy model download - we provide it as a dependency instead 44 - substituteInPlace misaki/en.py \ 45 - --replace-fail "if not spacy.util.is_package(name):" "if False:" \ 46 - --replace-fail "spacy.cli.download(name)" "pass" 47 - ''; 48 - 49 - propagatedBuildInputs = (old.propagatedBuildInputs or [ ]) ++ [ final.spacy-en-core-web-sm ]; 50 - }); 51 - }; 52 - }; 53 - in 54 - python.pkgs.kokoro
-153
5pkgs/kokoro/stdout-support.patch
··· 1 - --- a/kokoro/__main__.py 2025-12-21 20:28:56.595838972 +0100 2 - +++ b/kokoro/__main__.py 2025-12-21 20:50:06.842504063 +0100 3 - @@ -3,7 +3,10 @@ 4 - python3 -m kokoro --text "The sky above the port was the color of television, tuned to a dead channel." -o file.wav --debug 5 - 6 - echo "Bom dia mundo, como vão vocês" > text.txt 7 - -python3 -m kokoro -i text.txt -l p --voice pm_alex > audio.wav 8 - +python3 -m kokoro -i text.txt -l p --voice pm_alex -o output.wav 9 - + 10 - +# Streaming from stdin to stdout (pipe to mpv): 11 - +ollama run llama3 "tell me a joke" | python3 -m kokoro -o - --stream | mpv --demuxer=rawaudio --demuxer-rawaudio-rate=24000 --demuxer-rawaudio-format=s16le --demuxer-rawaudio-channels=1 - 12 - 13 - Common issues: 14 - pip not installed: `uv pip install pip` 15 - @@ -13,6 +16,7 @@ 16 - """ 17 - 18 - import argparse 19 - +import sys 20 - import wave 21 - from pathlib import Path 22 - from typing import Generator, TYPE_CHECKING 23 - @@ -47,22 +51,58 @@ 24 - yield from pipeline(text, voice=voice, speed=speed, split_pattern=r"\n+") 25 - 26 - 27 - +def write_wav_audio(wav_file, text: str, kokoro_language: str, voice: str, speed=1) -> None: 28 - + """Write audio frames to an open wav file handle.""" 29 - + wav_file.setnchannels(1) # Mono audio 30 - + wav_file.setsampwidth(2) # 2 bytes per sample (16-bit audio) 31 - + wav_file.setframerate(24000) # Sample rate 32 - + 33 - + for result in generate_audio( 34 - + text, kokoro_language=kokoro_language, voice=voice, speed=speed 35 - + ): 36 - + logger.debug(result.phonemes) 37 - + if result.audio is None: 38 - + continue 39 - + audio_bytes = (result.audio.numpy() * 32767).astype(np.int16).tobytes() 40 - + wav_file.writeframes(audio_bytes) 41 - + 42 - + 43 - def generate_and_save_audio( 44 - - output_file: Path, text: str, kokoro_language: str, voice: str, speed=1 45 - + output_file: str, text: str, kokoro_language: str, voice: str, speed=1 46 - +) -> None: 47 - + if output_file == "-": 48 - + # Write to stdout 49 - + with wave.open(sys.stdout.buffer, "wb") as wav_file: 50 - + write_wav_audio(wav_file, text, kokoro_language, voice, speed) 51 - + else: 52 - + with wave.open(str(Path(output_file).resolve()), "wb") as wav_file: 53 - + write_wav_audio(wav_file, text, kokoro_language, voice, speed) 54 - + 55 - + 56 - +def stream_audio_raw( 57 - + input_stream, kokoro_language: str, voice: str, speed: float 58 - ) -> None: 59 - - with wave.open(str(output_file.resolve()), "wb") as wav_file: 60 - - wav_file.setnchannels(1) # Mono audio 61 - - wav_file.setsampwidth(2) # 2 bytes per sample (16-bit audio) 62 - - wav_file.setframerate(24000) # Sample rate 63 - - 64 - - for result in generate_audio( 65 - - text, kokoro_language=kokoro_language, voice=voice, speed=speed 66 - - ): 67 - + """Stream audio as raw PCM s16le to stdout, processing stdin line by line.""" 68 - + from kokoro import KPipeline 69 - + 70 - + if not voice.startswith(kokoro_language): 71 - + logger.warning(f"Voice {voice} is not made for language {kokoro_language}") 72 - + 73 - + pipeline = KPipeline(lang_code=kokoro_language) 74 - + output = sys.stdout.buffer 75 - + 76 - + for line in input_stream: 77 - + line = line.rstrip('\n\r') 78 - + if not line: 79 - + continue 80 - + logger.debug(f"Processing line: {line!r}") 81 - + for result in pipeline(line, voice=voice, speed=speed, split_pattern=r"\n+"): 82 - logger.debug(result.phonemes) 83 - if result.audio is None: 84 - continue 85 - audio_bytes = (result.audio.numpy() * 32767).astype(np.int16).tobytes() 86 - - wav_file.writeframes(audio_bytes) 87 - + output.write(audio_bytes) 88 - + output.flush() 89 - 90 - 91 - def main() -> None: 92 - @@ -83,8 +123,8 @@ 93 - "-o", 94 - "--output-file", 95 - "--output_file", 96 - - type=Path, 97 - - help="Path to output WAV file", 98 - + type=str, 99 - + help="Path to output WAV file (use - for stdout)", 100 - required=True, 101 - ) 102 - parser.add_argument( 103 - @@ -107,6 +147,11 @@ 104 - help="Speech speed", 105 - ) 106 - parser.add_argument( 107 - + "--stream", 108 - + action="store_true", 109 - + help="Stream mode: read stdin line-by-line, output raw PCM s16le 24kHz mono (for piping to mpv)", 110 - + ) 111 - + parser.add_argument( 112 - "--debug", 113 - action="store_true", 114 - help="Print DEBUG messages to console", 115 - @@ -118,6 +163,20 @@ 116 - 117 - lang = args.language or args.voice[0] 118 - 119 - + # Streaming mode: read stdin line by line, output raw PCM 120 - + if args.stream: 121 - + if args.output_file != "-": 122 - + logger.warning("Streaming mode always outputs to stdout, ignoring output file") 123 - + if args.text: 124 - + raise Exception("Cannot use --text with --stream, use stdin") 125 - + if args.input_file: 126 - + with open(args.input_file) as f: 127 - + stream_audio_raw(f, lang, args.voice, args.speed) 128 - + else: 129 - + stream_audio_raw(sys.stdin, lang, args.voice, args.speed) 130 - + return 131 - + 132 - + # Non-streaming mode: read all input, output WAV 133 - if args.text is not None and args.input_file is not None: 134 - raise Exception("You cannot specify both 'text' and 'input_file'") 135 - elif args.text: 136 - @@ -126,14 +185,13 @@ 137 - file: Path = args.input_file 138 - text = file.read_text() 139 - else: 140 - - import sys 141 - - print("Press Ctrl+D to stop reading input and start generating", flush=True) 142 - + print("Press Ctrl+D to stop reading input and start generating", file=sys.stderr, flush=True) 143 - text = '\n'.join(sys.stdin) 144 - 145 - logger.debug(f"Input text: {text!r}") 146 - 147 - - out_file: Path = args.output_file 148 - - if not out_file.suffix == ".wav": 149 - + out_file: str = args.output_file 150 - + if out_file != "-" and not out_file.endswith(".wav"): 151 - logger.warning("The output file name should end with .wav") 152 - generate_and_save_audio( 153 - output_file=out_file,
-157
5pkgs/levenshtein-merge/levenshtein.c
··· 1 - #include <stdio.h> 2 - #include <stdlib.h> 3 - #include <string.h> 4 - 5 - static int min3(int a, int b, int c) { 6 - if (a < b) return a < c ? a : c; 7 - return b < c ? b : c; 8 - } 9 - 10 - static int distance(const char *a, const char *b) { 11 - size_t la = strlen(a), lb = strlen(b); 12 - int *prev = calloc(lb + 1, sizeof(int)); 13 - if (!prev) exit(1); 14 - for (size_t j = 0; j <= lb; j++) prev[j] = (int)j; 15 - for (size_t i = 1; i <= la; i++) { 16 - int corner = prev[0]; 17 - prev[0] = (int)i; 18 - for (size_t j = 1; j <= lb; j++) { 19 - int val = min3(prev[j] + 1, prev[j-1] + 1, 20 - corner + (a[i-1] != b[j-1])); 21 - corner = prev[j]; 22 - prev[j] = val; 23 - } 24 - } 25 - int res = prev[lb]; 26 - free(prev); 27 - return res; 28 - } 29 - 30 - /* Collapse whitespace and trim. Returns malloc'd string. */ 31 - static char *normalize(const char *s) { 32 - while (*s == ' ' || *s == '\t') s++; 33 - size_t len = strlen(s); 34 - char *out = malloc(len + 1); 35 - if (!out) exit(1); 36 - size_t o = 0; 37 - int in_space = 0; 38 - for (size_t i = 0; i < len; i++) { 39 - if (s[i] == ' ' || s[i] == '\t') { 40 - if (!in_space && o > 0) out[o++] = ' '; 41 - in_space = 1; 42 - } else { 43 - out[o++] = s[i]; 44 - in_space = 0; 45 - } 46 - } 47 - if (o > 0 && out[o-1] == ' ') o--; 48 - out[o] = '\0'; 49 - return out; 50 - } 51 - 52 - static int fuzzy_match(const char *a, const char *b, double threshold) { 53 - int dist = distance(a, b); 54 - size_t la = strlen(a), lb = strlen(b); 55 - size_t m = la > lb ? la : lb; 56 - if (m == 0) return 1; 57 - return (double)dist / (double)m <= threshold; 58 - } 59 - 60 - /* Dynamic line array */ 61 - struct lines { char **l; size_t n, cap; }; 62 - 63 - static void lines_init(struct lines *v) { 64 - v->n = 0; v->cap = 64; 65 - v->l = malloc(v->cap * sizeof(char *)); 66 - if (!v->l) exit(1); 67 - } 68 - 69 - static void lines_push(struct lines *v, char *s) { 70 - if (v->n == v->cap) { 71 - v->cap *= 2; 72 - v->l = realloc(v->l, v->cap * sizeof(char *)); 73 - if (!v->l) exit(1); 74 - } 75 - v->l[v->n++] = s; 76 - } 77 - 78 - static void read_lines(const char *path, struct lines *v) { 79 - FILE *f = fopen(path, "r"); 80 - if (!f) return; 81 - char *line = NULL; 82 - size_t cap = 0; 83 - ssize_t len; 84 - while ((len = getline(&line, &cap, f)) != -1) { 85 - if (len > 0 && line[len-1] == '\n') line[len-1] = '\0'; 86 - lines_push(v, strdup(line)); 87 - } 88 - free(line); 89 - fclose(f); 90 - } 91 - 92 - int main(int argc, char **argv) { 93 - if (argc == 3) { 94 - printf("%d\n", distance(argv[1], argv[2])); 95 - return 0; 96 - } 97 - if (argc == 5 && strcmp(argv[1], "-t") == 0) { 98 - double t = atof(argv[2]); 99 - return fuzzy_match(argv[3], argv[4], t) ? 0 : 1; 100 - } 101 - if (argc == 5 && strcmp(argv[1], "merge") == 0) { 102 - double threshold = atof(argv[4]); 103 - 104 - struct lines target, managed, known; 105 - lines_init(&target); 106 - lines_init(&managed); 107 - lines_init(&known); 108 - 109 - read_lines(argv[3], &target); 110 - read_lines(argv[2], &managed); 111 - 112 - /* Build normalized index of target lines */ 113 - for (size_t i = 0; i < target.n; i++) { 114 - char *n = normalize(target.l[i]); 115 - if (n[0]) lines_push(&known, n); 116 - else free(n); 117 - } 118 - 119 - /* Find managed lines missing from target */ 120 - struct lines missing; 121 - lines_init(&missing); 122 - 123 - for (size_t i = 0; i < managed.n; i++) { 124 - char *n = normalize(managed.l[i]); 125 - if (!n[0]) { free(n); continue; } 126 - int found = 0; 127 - for (size_t j = 0; j < known.n; j++) { 128 - if (fuzzy_match(n, known.l[j], threshold)) { 129 - found = 1; 130 - break; 131 - } 132 - } 133 - if (!found) { 134 - lines_push(&missing, managed.l[i]); 135 - lines_push(&known, n); 136 - } else { 137 - free(n); 138 - } 139 - } 140 - 141 - if (missing.n == 0) return 0; 142 - 143 - FILE *f = fopen(argv[3], "a"); 144 - if (!f) { perror(argv[3]); return 1; } 145 - fprintf(f, "\n"); 146 - for (size_t i = 0; i < missing.n; i++) 147 - fprintf(f, "%s\n", missing.l[i]); 148 - fclose(f); 149 - return 0; 150 - } 151 - 152 - fprintf(stderr, 153 - "usage: levenshtein-merge <a> <b>\n" 154 - " levenshtein-merge -t <threshold> <a> <b>\n" 155 - " levenshtein-merge merge <managed> <target> <threshold>\n"); 156 - return 2; 157 - }
-25
5pkgs/levenshtein-merge/package.nix
··· 1 - { 2 - lib, 3 - stdenv, 4 - }: 5 - stdenv.mkDerivation { 6 - pname = "levenshtein-merge"; 7 - version = "0.1.0"; 8 - 9 - src = ./.; 10 - 11 - buildPhase = '' 12 - $CC -O2 -o levenshtein-merge levenshtein.c 13 - ''; 14 - 15 - installPhase = '' 16 - install -Dm755 levenshtein-merge $out/bin/levenshtein-merge 17 - ''; 18 - 19 - meta = { 20 - description = "Fuzzy line-level file merge using Levenshtein distance"; 21 - license = lib.licenses.mit; 22 - mainProgram = "levenshtein-merge"; 23 - platforms = lib.platforms.all; 24 - }; 25 - }
-18
5pkgs/libretro-dolphin/disable-background-shaders.patch
··· 1 - diff --git a/Source/Core/VideoBackends/OGL/Render.cpp b/Source/Core/VideoBackends/OGL/Render.cpp 2 - --- a/Source/Core/VideoBackends/OGL/Render.cpp 3 - +++ b/Source/Core/VideoBackends/OGL/Render.cpp 4 - @@ -674,8 +674,14 @@ Renderer::Renderer(std::unique_ptr<GLContext> main_gl_context, float backbuffer_ 5 - g_Config.backend_info.bSupportsComputeShaders && g_ogl_config.bSupportsImageLoadStore; 6 - 7 - // Background compiling is supported only when shared contexts aren't broken. 8 - +#ifdef __LIBRETRO__ 9 - + // Libretro cannot create shared GL contexts via CreateSharedContext(), 10 - + // so disable background shader compilation entirely. 11 - + g_Config.backend_info.bSupportsBackgroundCompiling = false; 12 - +#else 13 - g_Config.backend_info.bSupportsBackgroundCompiling = 14 - !DriverDetails::HasBug(DriverDetails::BUG_SHARED_CONTEXT_SHADER_COMPILATION); 15 - +#endif 16 - 17 - // Program binaries are supported on GL4.1+, ARB_get_program_binary, or ES3. 18 - if (supports_glsl_cache)
-9
5pkgs/libretro-dolphin/package.nix
··· 1 - { 2 - libretro, 3 - }: 4 - 5 - libretro.dolphin.overrideAttrs (old: { 6 - patches = (old.patches or [ ]) ++ [ 7 - ./disable-background-shaders.patch 8 - ]; 9 - })
-142
5pkgs/moebius/package.nix
··· 1 - { 2 - lib, 3 - stdenv, 4 - buildNpmPackage, 5 - fetchFromGitHub, 6 - makeWrapper, 7 - makeDesktopItem, 8 - copyDesktopItems, 9 - electron_43, 10 - }: 11 - 12 - let 13 - electron = electron_43; 14 - in 15 - buildNpmPackage (finalAttrs: { 16 - pname = "moebius"; 17 - # Upstream's last tagged release (1.0.29) only ships x86_64 binaries; build the 18 - # newer master commit from source so it runs natively on every platform. 19 - version = "1.0.29-unstable-2023-12-18"; 20 - 21 - src = fetchFromGitHub { 22 - owner = "blocktronics"; 23 - repo = "moebius"; 24 - rev = "1f624d62654d614177635ab18a288253c3b917eb"; 25 - hash = "sha256-Uu6vEhbXXiVbUTgC/WeF+SVCf0oLzg4OnGFZIx7SJ6I="; 26 - }; 27 - 28 - npmDepsHash = "sha256-hLZF0FvyAgJVURmU6M4UYFauZz5SLvH2+6W2qDogoXU="; 29 - 30 - # discord-rpc pulls the optional native `register-scheme` git dependency, which 31 - # is only used outside Electron (inside Electron it falls back to 32 - # app.setAsDefaultProtocolClient), so drop it and build a pure-JS closure. 33 - forceGitDeps = true; 34 - npmFlags = [ 35 - "--omit=dev" 36 - "--omit=optional" 37 - ]; 38 - 39 - # We ship the app on nixpkgs' Electron; don't download a private one or run the 40 - # electron-builder packaging step. 41 - env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; 42 - dontNpmBuild = true; 43 - 44 - nativeBuildInputs = [ 45 - makeWrapper 46 - ] 47 - ++ lib.optionals stdenv.hostPlatform.isLinux [ copyDesktopItems ]; 48 - 49 - desktopItems = lib.optionals stdenv.hostPlatform.isLinux [ 50 - (makeDesktopItem { 51 - name = "moebius"; 52 - desktopName = "Moebius"; 53 - comment = finalAttrs.meta.description; 54 - exec = "moebius %U"; 55 - icon = "moebius"; 56 - startupWMClass = "Moebius"; 57 - categories = [ "Graphics" ]; 58 - mimeTypes = [ "text/x-ansi" ]; 59 - }) 60 - ]; 61 - 62 - installPhase = '' 63 - runHook preInstall 64 - 65 - app=$out/libexec/moebius 66 - res=$app/resources 67 - mkdir -p "$app" "$out/bin" 68 - cp -r app node_modules package.json "$app/" 69 - 70 - # Sample art and control-character sheets the app loads at runtime. 71 - mkdir -p "$res" 72 - cp -r build/ans "$res/ans" 73 - cp -r build/png "$res/png" 74 - 75 - # The app switches resource paths on app.isPackaged; we run an unpacked tree, 76 - # so pin both branches to the store copy above. 77 - find "$app/app" -name '*.js' | while read -r f; do 78 - substituteInPlace "$f" \ 79 - --replace-quiet 'remote.app.isPackaged' 'true' \ 80 - --replace-quiet 'process.resourcesPath' "\"$res\"" 81 - done 82 - 83 - # @electron/remote 2.1.0 calls the internal features.isViewApiEnabled(), 84 - # removed in modern Electron; guard it (the fix shipped upstream in 2.1.2). 85 - substituteInPlace "$app/node_modules/@electron/remote/dist/src/common/module-names.js" \ 86 - --replace-fail 'if (!features || features.isViewApiEnabled()) {' \ 87 - 'if (!features || !features.isViewApiEnabled || features.isViewApiEnabled()) {' 88 - 89 - makeWrapper ${lib.getExe electron} $out/bin/moebius \ 90 - --add-flags "$app" \ 91 - ${lib.optionalString stdenv.hostPlatform.isLinux '' 92 - --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" 93 - ''} 94 - '' 95 - + lib.optionalString stdenv.hostPlatform.isLinux '' 96 - for size in 16 32 128 512 1024; do 97 - install -Dm644 build/icons/icon_''${size}x''${size}.png \ 98 - $out/share/icons/hicolor/''${size}x''${size}/apps/moebius.png 99 - done 100 - '' 101 - + lib.optionalString stdenv.hostPlatform.isDarwin '' 102 - appdir="$out/Applications/Moebius.app/Contents" 103 - mkdir -p "$appdir/MacOS" "$appdir/Resources" 104 - cp build/icon.icns "$appdir/Resources/moebius.icns" 105 - makeWrapper $out/bin/moebius "$appdir/MacOS/Moebius" 106 - cat > "$appdir/Info.plist" <<EOF 107 - <?xml version="1.0" encoding="UTF-8"?> 108 - <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 109 - <plist version="1.0"> 110 - <dict> 111 - <key>CFBundleName</key><string>Moebius</string> 112 - <key>CFBundleDisplayName</key><string>Moebius</string> 113 - <key>CFBundleExecutable</key><string>Moebius</string> 114 - <key>CFBundleIdentifier</key><string>org.andyherbert.moebius</string> 115 - <key>CFBundleIconFile</key><string>moebius.icns</string> 116 - <key>CFBundleShortVersionString</key><string>${finalAttrs.version}</string> 117 - <key>CFBundlePackageType</key><string>APPL</string> 118 - <key>LSMinimumSystemVersion</key><string>10.13</string> 119 - <key>NSHighResolutionCapable</key><true/> 120 - </dict> 121 - </plist> 122 - EOF 123 - '' 124 - + '' 125 - runHook postInstall 126 - ''; 127 - 128 - meta = { 129 - description = "Modern ANSI and ASCII art editor with a Photoshop-style half-block brush"; 130 - homepage = "https://blocktronics.github.io/moebius/"; 131 - downloadPage = "https://github.com/blocktronics/moebius/releases"; 132 - license = lib.licenses.asl20; 133 - maintainers = [ ]; 134 - platforms = [ 135 - "x86_64-linux" 136 - "aarch64-linux" 137 - "x86_64-darwin" 138 - "aarch64-darwin" 139 - ]; 140 - mainProgram = "moebius"; 141 - }; 142 - })
-17
5pkgs/nm-dmenu/package.nix
··· 1 - { pkgs, lib, ... }: 2 - 3 - (pkgs.writers.writeDashBin "nm-dmenu" '' 4 - export PATH=$PATH:${ 5 - lib.makeBinPath [ 6 - (pkgs.writers.writeDashBin "dmenu" '' 7 - exec ${lib.getExe pkgs.rofi} -dmenu "$@" 8 - '') 9 - pkgs.networkmanagerapplet 10 - pkgs.procps 11 - ] 12 - } 13 - exec ${pkgs.networkmanager_dmenu}/bin/networkmanager_dmenu "$@" 14 - '') 15 - // { 16 - meta.platforms = lib.platforms.linux; 17 - }
-179
5pkgs/omnigent/package.nix
··· 1 - { 2 - lib, 3 - fetchPypi, 4 - python313Packages, 5 - }: 6 - let 7 - py = python313Packages; 8 - 9 - # 0.18.1 added a runtime dependency on `websockets` (realtime transport) that 10 - # nixpkgs' derivation does not declare yet, so its wheel fails the 11 - # runtime-deps check. Add it until nixpkgs catches up. 12 - openai-agents = py.openai-agents.overridePythonAttrs (o: { 13 - dependencies = o.dependencies ++ [ py.websockets ]; 14 - }); 15 - 16 - # omnigent-client declares `omnigent` as a dependency, and omnigent declares 17 - # omnigent-client: the two are released in lockstep from the same repo. Break 18 - # the cycle by dropping the back-reference here; the final application below 19 - # provides `omnigent` itself. 20 - omnigent-client = py.buildPythonPackage rec { 21 - pname = "omnigent_client"; 22 - version = "0.9.0"; 23 - format = "wheel"; 24 - 25 - src = fetchPypi { 26 - inherit pname version format; 27 - dist = "py3"; 28 - python = "py3"; 29 - hash = "sha256-NUF16ZzZkVzohOKznfAPVTT1LI09dXP6gZxlWZzmyrM="; 30 - }; 31 - 32 - pythonRemoveDeps = [ "omnigent" ]; 33 - 34 - dependencies = [ 35 - py.httpx 36 - py.pydantic 37 - ]; 38 - 39 - # No pythonImportsCheck: every module imports `omnigent`, which only exists 40 - # in the application closure below. 41 - doCheck = false; 42 - 43 - meta = { 44 - description = "Python client library for the Omnigent server API"; 45 - homepage = "https://omnigent.ai"; 46 - license = lib.licenses.asl20; 47 - }; 48 - }; 49 - 50 - omnigent-ui-sdk = py.buildPythonPackage rec { 51 - pname = "omnigent_ui_sdk"; 52 - version = "0.9.0"; 53 - format = "wheel"; 54 - 55 - src = fetchPypi { 56 - inherit pname version format; 57 - dist = "py3"; 58 - python = "py3"; 59 - hash = "sha256-xr5rtx2Ac/q8GShkeQudzbKDoLGe5d+Jp7XCiXzU7lQ="; 60 - }; 61 - 62 - dependencies = [ 63 - omnigent-client 64 - py.prompt-toolkit 65 - py.pyyaml 66 - py.rich 67 - ]; 68 - 69 - doCheck = false; 70 - 71 - meta = { 72 - description = "Terminal UI SDK for Omnigent"; 73 - homepage = "https://omnigent.ai"; 74 - license = lib.licenses.asl20; 75 - }; 76 - }; 77 - in 78 - py.buildPythonApplication rec { 79 - pname = "omnigent"; 80 - version = "0.9.0"; 81 - format = "wheel"; 82 - 83 - # The published wheel ships the prebuilt web SPA under 84 - # omnigent/server/static/web-ui; the sdist does not. 85 - src = fetchPypi { 86 - inherit pname version format; 87 - dist = "py3"; 88 - python = "py3"; 89 - hash = "sha256-deNx9SHyaf1ZNkq5+88c1fUieFZastIo8N27Ixy0F1Q="; 90 - }; 91 - 92 - # Upper bounds that nixpkgs has already moved past. All of these are 93 - # conservative caps in omnigent's pyproject, not known incompatibilities. 94 - pythonRelaxDeps = [ 95 - "argon2-cffi" # nixpkgs 25.1.0 vs <24 96 - "cachetools" # nixpkgs 7.1.4 vs <7 97 - "openai" # nixpkgs 2.53.0 vs <2.45 98 - "packaging" # nixpkgs 26.2 vs <26 99 - "protobuf" # nixpkgs 7.35.1 vs <7 100 - "rich" # nixpkgs 15.0.0 vs <15 101 - "websockets" # nixpkgs 16.1 vs <15 102 - ]; 103 - 104 - dependencies = [ 105 - omnigent-client 106 - omnigent-ui-sdk 107 - ] 108 - ++ (with py; [ 109 - alembic 110 - anyio 111 - argon2-cffi 112 - cachetools 113 - cel-python 114 - certifi 115 - claude-agent-sdk 116 - click 117 - fastapi 118 - ftfy 119 - httpx 120 - json5 121 - keyring 122 - mcp 123 - openai 124 - openai-agents 125 - opentelemetry-exporter-otlp-proto-grpc 126 - opentelemetry-exporter-otlp-proto-http 127 - opentelemetry-instrumentation-fastapi 128 - opentelemetry-instrumentation-httpx 129 - opentelemetry-instrumentation-sqlalchemy 130 - packaging 131 - pexpect 132 - prompt-toolkit 133 - protobuf 134 - psutil 135 - pydantic 136 - pyjwt 137 - pyte 138 - python-dateutil 139 - pyyaml 140 - rich 141 - sqlalchemy 142 - starlette 143 - tiktoken 144 - tomlkit 145 - uvicorn 146 - websockets 147 - zstandard 148 - ]) 149 - ++ py.pyjwt.optional-dependencies.crypto 150 - ++ py.uvicorn.optional-dependencies.standard; 151 - 152 - # The CLI respawns itself as `sys.executable -m omnigent.host._daemon_entry` 153 - # (cli.py:2945) for the host daemon, and the daemon spawns runners the same 154 - # way. That is the *bare* interpreter, without the site-dirs that 155 - # wrapPythonPrograms bakes into the console script, so those children die 156 - # with ModuleNotFoundError: No module named 'omnigent' and every session hangs 157 - # on "the connect daemon did not come online within 30s". Hand them the 158 - # transitive dependency closure through PYTHONPATH, which omnigent's own 159 - # daemon/runner env allowlist preserves (host/connect.py) while the agent 160 - # spawn allowlist drops it (inner/agent_env.py), so it never reaches omp. 161 - postFixup = '' 162 - buildPythonPath "$out $pythonPath" 163 - wrapProgram $out/bin/omnigent --prefix PYTHONPATH : "$program_PYTHONPATH" 164 - ''; 165 - 166 - pythonImportsCheck = [ 167 - "omnigent" 168 - "omnigent_client" 169 - "omnigent_ui_sdk" 170 - ]; 171 - 172 - meta = { 173 - description = "Open-source meta-harness giving a common orchestration layer over AI coding agents"; 174 - homepage = "https://omnigent.ai"; 175 - license = lib.licenses.asl20; 176 - mainProgram = "omnigent"; 177 - platforms = lib.platforms.unix; 178 - }; 179 - }
-53
5pkgs/parley/package.nix
··· 1 - { 2 - lib, 3 - buildGoModule, 4 - fetchFromGitea, 5 - go_1_27, 6 - }: 7 - 8 - # go.mod requires go 1.27; nixpkgs' default go is still 1.26. 9 - (buildGoModule.override { go = go_1_27; }) ( 10 - finalAttrs: 11 - let 12 - rev = "52118a9e2475bd3f8c4d62e3032eaf821a9a239d"; 13 - in 14 - { 15 - pname = "parley"; 16 - version = "0.5.0-unstable-2026-09-27"; 17 - 18 - src = fetchFromGitea { 19 - domain = "git.mills.io"; 20 - owner = "prologic"; 21 - repo = "parley"; 22 - inherit rev; 23 - hash = "sha256-bFeX5Nd8IvtY6Di+YUCZtclGkV2bTdIxTWpivCtRqLM="; 24 - }; 25 - 26 - vendorHash = "sha256-siSavE8i/Rh6LfI+HXhyKsPrbfyncJCcEYJsA4KZ378="; 27 - 28 - # modernc.org/sqlite is pure Go; upstream builds static binaries. 29 - env.CGO_ENABLED = 0; 30 - 31 - # The generated templ components and the compiled stylesheet are 32 - # committed upstream, so neither templ nor tailwind is needed here. 33 - subPackages = [ 34 - "cmd/parleyd" 35 - "cmd/parleyctl" 36 - ]; 37 - 38 - ldflags = [ 39 - "-s" 40 - "-w" 41 - "-X main.Version=${finalAttrs.version}" 42 - "-X main.Commit=${builtins.substring 0 7 rev}" 43 - ]; 44 - 45 - meta = { 46 - description = "Federated, decentralised chat that speaks plain IRC"; 47 - homepage = "https://git.mills.io/prologic/parley"; 48 - license = lib.licenses.mit; 49 - mainProgram = "parleyd"; 50 - platforms = lib.platforms.unix; 51 - }; 52 - } 53 - )
-35
5pkgs/phonetpm/package.nix
··· 1 - { 2 - lib, 3 - rustPlatform, 4 - fetchFromGitHub, 5 - }: 6 - 7 - rustPlatform.buildRustPackage rec { 8 - pname = "phonetpm"; 9 - version = "0.1.0"; 10 - 11 - src = fetchFromGitHub { 12 - owner = "Lassulus"; 13 - repo = "phonetpm"; 14 - tag = "v${version}"; 15 - hash = "sha256-8WjjbDDHZeYhtnoj/judDrsWT68Z4lnpySM8rSqP+Ao="; 16 - }; 17 - 18 - cargoHash = "sha256-VNw5Hw8E0ReSJhYVLh6XOzQRSyqfFe3uu0522XOeaoA="; 19 - 20 - # Host side only: `phonetpm` (daemon/pair/keys) and `age-plugin-phone`. 21 - # The workspace also holds the Android core (uniffi cdylib), not needed here. 22 - cargoBuildFlags = [ 23 - "-p" 24 - "phonetpm" 25 - ]; 26 - cargoTestFlags = cargoBuildFlags; 27 - 28 - meta = { 29 - description = "Android phone as a biometric ssh-agent and age backend over iroh"; 30 - homepage = "https://github.com/Lassulus/phonetpm"; 31 - license = lib.licenses.mit; 32 - mainProgram = "phonetpm"; 33 - platforms = lib.platforms.linux; 34 - }; 35 - }
-10
5pkgs/pop/package.nix
··· 1 - { pkgs, ... }: 2 - 3 - pkgs.writeScriptBin "pop" '' 4 - #! ${pkgs.bash}/bin/bash 5 - 6 - file=$1 7 - 8 - head -1 $file 9 - sed -i 1d $file 10 - ''
-218
5pkgs/sdrpp-brown/package.nix
··· 1 - { 2 - stdenv, 3 - lib, 4 - fetchFromGitHub, 5 - fetchurl, 6 - cmake, 7 - pkg-config, 8 - unzip, 9 - libX11, 10 - glfw, 11 - glew, 12 - fftw, 13 - fftwFloat, 14 - volk, 15 - zstd, 16 - libpulseaudio, 17 - 18 - # Sources 19 - airspy_source ? true, 20 - airspy, 21 - airspyhf_source ? true, 22 - airspyhf, 23 - audio_source ? true, 24 - bladerf_source ? stdenv.hostPlatform.isLinux, 25 - libbladeRF, 26 - file_source ? true, 27 - hackrf_source ? true, 28 - hackrf, 29 - hermes_source ? true, 30 - hl2_source ? true, 31 - kiwisdr_source ? true, 32 - limesdr_source ? true, 33 - limesuite, 34 - network_source ? true, 35 - plutosdr_source ? stdenv.hostPlatform.isLinux, 36 - libiio, 37 - libad9361, 38 - rfspace_source ? true, 39 - rtl_sdr_source ? true, 40 - rtl-sdr-osmocom, 41 - libusb1, 42 - rtl_tcp_source ? true, 43 - sdrplay_source ? false, 44 - sdrplay, 45 - sdrpp_server_source ? true, 46 - soapy_source ? true, 47 - soapysdr-with-plugins, 48 - spectran_http_source ? true, 49 - spyserver_source ? true, 50 - usrp_source ? false, 51 - uhd, 52 - boost, 53 - 54 - # Sinks 55 - audio_sink ? true, 56 - rtaudio, 57 - mpeg_adts_sink ? false, # downloads lame at build time - disable for now 58 - network_sink ? true, 59 - portaudio_sink ? false, 60 - portaudio, 61 - 62 - # Decoders 63 - atv_decoder ? true, 64 - ch_extravhf_decoder ? false, # requires itpp/mbe libraries 65 - ch_tetra_demodulator ? true, 66 - dab_decoder ? false, 67 - falcon9_decoder ? false, 68 - ft8_decoder ? true, 69 - kg_sstv_decoder ? false, 70 - m17_decoder ? false, 71 - codec2, 72 - meteor_demodulator ? true, 73 - pager_decoder ? true, 74 - radio ? true, 75 - weather_sat_decoder ? false, 76 - 77 - # Misc 78 - discord_presence ? true, 79 - frequency_manager ? true, 80 - iq_exporter ? true, 81 - recorder ? true, 82 - rigctl_client ? true, 83 - rigctl_server ? true, 84 - scanner ? true, 85 - noise_reduction ? true, 86 - }: 87 - 88 - let 89 - # ETSI TETRA codec - pre-fetched to avoid network access during build 90 - etsiCodec = fetchurl { 91 - url = "http://www.etsi.org/deliver/etsi_en/300300_300399/30039502/01.03.01_60/en_30039502v010301p0.zip"; 92 - hash = "sha256-H+GMR3PIzLUu8jyltKCwhBs41U3dXZx9huuLBg8TLzk="; 93 - }; 94 - in 95 - stdenv.mkDerivation (_finalAttrs: { 96 - pname = "sdrpp-brown"; 97 - 98 - version = "0-unstable-2025-12-29"; 99 - 100 - src = fetchFromGitHub { 101 - owner = "sannysanoff"; 102 - repo = "SDRPlusPlusBrown"; 103 - rev = "f0feccfb10a9a102255c771ec3f5923c6f58ad9e"; 104 - hash = "sha256-2DeDWoWRTv6lcF+DQyaVx2oJsjlGsUf88MrF3+7T6Ew="; 105 - }; 106 - 107 - nativeBuildInputs = [ 108 - cmake 109 - pkg-config 110 - ] 111 - ++ lib.optional ch_tetra_demodulator unzip; 112 - 113 - buildInputs = [ 114 - glfw 115 - glew 116 - fftw 117 - fftwFloat 118 - volk 119 - zstd 120 - ] 121 - ++ lib.optional stdenv.hostPlatform.isLinux libX11 122 - ++ lib.optional stdenv.hostPlatform.isLinux libpulseaudio 123 - ++ lib.optional airspy_source airspy 124 - ++ lib.optional airspyhf_source airspyhf 125 - ++ lib.optional bladerf_source libbladeRF 126 - ++ lib.optional hackrf_source hackrf 127 - ++ lib.optional limesdr_source limesuite 128 - ++ lib.optionals rtl_sdr_source [ 129 - rtl-sdr-osmocom 130 - libusb1 131 - ] 132 - ++ lib.optional sdrplay_source sdrplay 133 - ++ lib.optional soapy_source soapysdr-with-plugins 134 - ++ lib.optionals plutosdr_source [ 135 - libiio 136 - libad9361 137 - ] 138 - ++ lib.optionals usrp_source [ 139 - uhd 140 - boost 141 - ] 142 - ++ lib.optional (audio_source || audio_sink) rtaudio 143 - ++ lib.optional portaudio_sink portaudio 144 - ++ lib.optional (dab_decoder || m17_decoder) codec2; 145 - 146 - # Pre-place the ETSI codec in the build directory so CMake doesn't try to download it 147 - preConfigure = lib.optionalString ch_tetra_demodulator '' 148 - mkdir -p build/decoder_modules/ch_tetra_demodulator/etsi_codec 149 - cp ${etsiCodec} build/decoder_modules/ch_tetra_demodulator/etsi_codec/etsi_tetra_codec.zip 150 - ''; 151 - 152 - cmakeFlags = [ 153 - # Sources 154 - (lib.cmakeBool "OPT_BUILD_AIRSPYHF_SOURCE" airspyhf_source) 155 - (lib.cmakeBool "OPT_BUILD_AIRSPY_SOURCE" airspy_source) 156 - (lib.cmakeBool "OPT_BUILD_AUDIO_SOURCE" audio_source) 157 - (lib.cmakeBool "OPT_BUILD_BLADERF_SOURCE" bladerf_source) 158 - (lib.cmakeBool "OPT_BUILD_FILE_SOURCE" file_source) 159 - (lib.cmakeBool "OPT_BUILD_HACKRF_SOURCE" hackrf_source) 160 - (lib.cmakeBool "OPT_BUILD_HERMES_SOURCE" hermes_source) 161 - (lib.cmakeBool "OPT_BUILD_HL2_SOURCE" hl2_source) 162 - (lib.cmakeBool "OPT_BUILD_KIWISDR_SOURCE" kiwisdr_source) 163 - (lib.cmakeBool "OPT_BUILD_LIMESDR_SOURCE" limesdr_source) 164 - (lib.cmakeBool "OPT_BUILD_NETWORK_SOURCE" network_source) 165 - (lib.cmakeBool "OPT_BUILD_PLUTOSDR_SOURCE" plutosdr_source) 166 - (lib.cmakeBool "OPT_BUILD_RFSPACE_SOURCE" rfspace_source) 167 - (lib.cmakeBool "OPT_BUILD_RTL_SDR_SOURCE" rtl_sdr_source) 168 - (lib.cmakeBool "OPT_BUILD_RTL_TCP_SOURCE" rtl_tcp_source) 169 - (lib.cmakeBool "OPT_BUILD_SDRPLAY_SOURCE" sdrplay_source) 170 - (lib.cmakeBool "OPT_BUILD_SDRPP_SERVER_SOURCE" sdrpp_server_source) 171 - (lib.cmakeBool "OPT_BUILD_SOAPY_SOURCE" soapy_source) 172 - (lib.cmakeBool "OPT_BUILD_SPECTRAN_HTTP_SOURCE" spectran_http_source) 173 - (lib.cmakeBool "OPT_BUILD_SPYSERVER_SOURCE" spyserver_source) 174 - (lib.cmakeBool "OPT_BUILD_USRP_SOURCE" usrp_source) 175 - 176 - # Sinks 177 - (lib.cmakeBool "OPT_BUILD_AUDIO_SINK" audio_sink) 178 - (lib.cmakeBool "OPT_BUILD_MPEG_ADTS_SINK" mpeg_adts_sink) 179 - (lib.cmakeBool "OPT_BUILD_NETWORK_SINK" network_sink) 180 - (lib.cmakeBool "OPT_BUILD_NEW_PORTAUDIO_SINK" portaudio_sink) 181 - 182 - # Decoders 183 - (lib.cmakeBool "OPT_BUILD_ATV_DECODER" atv_decoder) 184 - (lib.cmakeBool "OPT_BUILD_CH_EXTRAVHF_DECODER" ch_extravhf_decoder) 185 - (lib.cmakeBool "OPT_BUILD_CH_TETRA_DEMODULATOR" ch_tetra_demodulator) 186 - (lib.cmakeBool "OPT_BUILD_DAB_DECODER" dab_decoder) 187 - (lib.cmakeBool "OPT_BUILD_FALCON9_DECODER" falcon9_decoder) 188 - (lib.cmakeBool "OPT_BUILD_FT8_DECODER" ft8_decoder) 189 - (lib.cmakeBool "OPT_BUILD_KG_SSTV_DECODER" kg_sstv_decoder) 190 - (lib.cmakeBool "OPT_BUILD_M17_DECODER" m17_decoder) 191 - (lib.cmakeBool "OPT_BUILD_METEOR_DEMODULATOR" meteor_demodulator) 192 - (lib.cmakeBool "OPT_BUILD_PAGER_DECODER" pager_decoder) 193 - (lib.cmakeBool "OPT_BUILD_RADIO" radio) 194 - (lib.cmakeBool "OPT_BUILD_WEATHER_SAT_DECODER" weather_sat_decoder) 195 - 196 - # Misc 197 - (lib.cmakeBool "OPT_BUILD_DISCORD_PRESENCE" discord_presence) 198 - (lib.cmakeBool "OPT_BUILD_FREQUENCY_MANAGER" frequency_manager) 199 - (lib.cmakeBool "OPT_BUILD_IQ_EXPORTER" iq_exporter) 200 - (lib.cmakeBool "OPT_BUILD_RECORDER" recorder) 201 - (lib.cmakeBool "OPT_BUILD_RIGCTL_CLIENT" rigctl_client) 202 - (lib.cmakeBool "OPT_BUILD_RIGCTL_SERVER" rigctl_server) 203 - (lib.cmakeBool "OPT_BUILD_SCANNER" scanner) 204 - (lib.cmakeBool "OPT_BUILD_NOISE_REDUCTION_LOGMMSE" noise_reduction) 205 - ]; 206 - 207 - env.NIX_CFLAGS_COMPILE = "-fpermissive"; 208 - 209 - hardeningDisable = lib.optional stdenv.cc.isClang "format"; 210 - 211 - meta = { 212 - description = "SDR++ Brown Edition - Cross-Platform SDR Software with KiwiSDR support"; 213 - homepage = "https://github.com/sannysanoff/SDRPlusPlusBrown"; 214 - license = lib.licenses.gpl3Only; 215 - platforms = lib.platforms.linux; 216 - mainProgram = "sdrpp"; 217 - }; 218 - })
-42
5pkgs/secretive/package.nix
··· 1 - { 2 - lib, 3 - stdenvNoCC, 4 - fetchzip, 5 - unzip, 6 - }: 7 - 8 - stdenvNoCC.mkDerivation rec { 9 - pname = "secretive"; 10 - version = "2.4.1"; 11 - 12 - src = fetchzip { 13 - url = "https://github.com/maxgoedjen/secretive/releases/download/v${version}/Secretive.zip"; 14 - hash = "sha256-JzG7AWpvo/ivtb5JARNCYIQiLuqFYe6Peg2T444Qv0M="; 15 - stripRoot = false; 16 - }; 17 - 18 - nativeBuildInputs = [ unzip ]; 19 - 20 - dontBuild = true; 21 - dontConfigure = true; 22 - dontFixup = true; 23 - 24 - installPhase = '' 25 - runHook preInstall 26 - 27 - mkdir -p $out/Applications 28 - cp -R Secretive.app $out/Applications/ 29 - 30 - runHook postInstall 31 - ''; 32 - 33 - meta = { 34 - description = "Store SSH keys in the Secure Enclave"; 35 - homepage = "https://github.com/maxgoedjen/secretive"; 36 - changelog = "https://github.com/maxgoedjen/secretive/releases/tag/v${version}"; 37 - license = lib.licenses.mit; 38 - maintainers = with lib.maintainers; [ ]; 39 - platforms = lib.platforms.darwin; 40 - mainProgram = "Secretive"; 41 - }; 42 - }
-320
5pkgs/ssh-tpm-agent/client-pid.patch
··· 1 - diff --git a/agent/agent.go b/agent/agent.go 2 - index ac39d24..b1e399b 100644 3 - --- a/agent/agent.go 4 - +++ b/agent/agent.go 5 - @@ -26,6 +26,7 @@ import ( 6 - "github.com/google/go-tpm/tpm2/transport" 7 - "golang.org/x/crypto/ssh" 8 - "golang.org/x/crypto/ssh/agent" 9 - + "golang.org/x/sys/unix" 10 - "golang.org/x/text/cases" 11 - "golang.org/x/text/language" 12 - ) 13 - @@ -39,17 +40,18 @@ var ( 14 - var SSH_TPM_AGENT_ADD = "tpm-add-key" 15 - 16 - type Agent struct { 17 - - mu sync.Mutex 18 - - tpm func() transport.TPMCloser 19 - - op func() ([]byte, error) 20 - - pin func(key.SSHTPMKeys) ([]byte, error) 21 - - listener *net.UnixListener 22 - - quit chan any 23 - - wg sync.WaitGroup 24 - - keyring func() *keyring.ThreadKeyring 25 - - keys []key.SSHTPMKeys 26 - - hierkeys []*key.HierSSHTPMKey 27 - - agents []agent.ExtendedAgent 28 - + mu sync.Mutex 29 - + tpm func() transport.TPMCloser 30 - + op func() ([]byte, error) 31 - + pin func(key.SSHTPMKeys, int) ([]byte, error) 32 - + listener *net.UnixListener 33 - + quit chan any 34 - + wg sync.WaitGroup 35 - + keyring func() *keyring.ThreadKeyring 36 - + keys []key.SSHTPMKeys 37 - + hierkeys []*key.HierSSHTPMKey 38 - + agents []agent.ExtendedAgent 39 - + clientPID int 40 - } 41 - 42 - var _ agent.ExtendedAgent = &Agent{} 43 - @@ -120,8 +122,8 @@ func (a *Agent) signers() ([]ssh.Signer, error) { 44 - s, err := ssh.NewSignerFromSigner(k.Signer( 45 - a.keyring(), a.op, a.tpm, 46 - func(_ *keyfile.TPMKey) ([]byte, error) { 47 - - // Shimming the function to get the correct type 48 - - return a.pin(k) 49 - + // Shimming the function to get the correct type, pass client PID 50 - + return a.pin(k, a.clientPID) 51 - }), 52 - ) 53 - if err != nil { 54 - @@ -260,7 +262,7 @@ func (a *Agent) confirmKeyUse(wantKey []byte, peer string) error { 55 - if peer != "" { 56 - prompt += "\nRequested by " + peer 57 - } 58 - - ok, err := askpass.AskPermission(prompt) 59 - + ok, err := askpass.AskPermission(prompt, a.clientPID) 60 - if err != nil { 61 - slog.Info("askpass confirmation failed", slog.String("error", err.Error())) 62 - return ErrUserDeniedConfirm 63 - @@ -289,6 +291,18 @@ func (c *connAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.Si 64 - } 65 - 66 - func (a *Agent) serveConn(c *net.UnixConn) { 67 - + // Extract peer credentials to get the client PID 68 - + if rc, err := c.SyscallConn(); err == nil { 69 - + rc.Control(func(fd uintptr) { 70 - + if cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED); err == nil { 71 - + a.mu.Lock() 72 - + a.clientPID = int(cred.Pid) 73 - + a.mu.Unlock() 74 - + slog.Debug("client connected", slog.Int("pid", int(cred.Pid))) 75 - + } 76 - + }) 77 - + } 78 - + 79 - ca := &connAgent{Agent: a, peer: peerChain(c)} 80 - if err := agent.ServeAgent(ca, c); err != io.EOF { 81 - slog.Info("Agent client connection ended unsuccessfully", slog.String("error", err.Error())) 82 - @@ -524,7 +538,7 @@ func LoadKeys(keyDir string) ([]key.SSHTPMKeys, error) { 83 - return keys, err 84 - } 85 - 86 - -func NewAgent(listener *net.UnixListener, agents []agent.ExtendedAgent, keyring func() *keyring.ThreadKeyring, tpmFetch func() transport.TPMCloser, ownerPassword func() ([]byte, error), pin func(key.SSHTPMKeys) ([]byte, error)) *Agent { 87 - +func NewAgent(listener *net.UnixListener, agents []agent.ExtendedAgent, keyring func() *keyring.ThreadKeyring, tpmFetch func() transport.TPMCloser, ownerPassword func() ([]byte, error), pin func(key.SSHTPMKeys, int) ([]byte, error)) *Agent { 88 - a := &Agent{ 89 - agents: agents, 90 - tpm: tpmFetch, 91 - diff --git a/agent/agent_test.go b/agent/agent_test.go 92 - index acaec3d..189b607 100644 93 - --- a/agent/agent_test.go 94 - +++ b/agent/agent_test.go 95 - @@ -45,7 +45,7 @@ func TestAddKey(t *testing.T) { 96 - // Owner password 97 - func() ([]byte, error) { return []byte(""), nil }, 98 - // PIN Callback 99 - - func(_ key.SSHTPMKeys) ([]byte, error) { return []byte(""), nil }, 100 - + func(_ key.SSHTPMKeys, _ int) ([]byte, error) { return []byte(""), nil }, 101 - ) 102 - defer ag.Stop() 103 - 104 - diff --git a/askpass/askpass.go b/askpass/askpass.go 105 - index 9830586..aefe469 100644 106 - --- a/askpass/askpass.go 107 - +++ b/askpass/askpass.go 108 - @@ -8,6 +8,7 @@ import ( 109 - "log/slog" 110 - "os" 111 - "os/exec" 112 - + "strconv" 113 - "strings" 114 - "syscall" 115 - 116 - @@ -66,7 +67,7 @@ func isTerminal(fd uintptr) bool { 117 - return err == nil 118 - } 119 - 120 - -func ReadPassphrase(prompt string, flags ReadPassFlags) ([]byte, error) { 121 - +func ReadPassphrase(prompt string, flags ReadPassFlags, clientPID int) ([]byte, error) { 122 - var allow_askpass bool 123 - var use_askpass bool 124 - var askpass_hint string 125 - @@ -104,7 +105,7 @@ func ReadPassphrase(prompt string, flags ReadPassFlags) ([]byte, error) { 126 - if (flags & RP_ASK_PERMISSION) != 0 { 127 - askpass_hint = "confirm" 128 - } 129 - - return SshAskPass(prompt, askpass_hint) 130 - + return SshAskPass(prompt, askpass_hint, clientPID) 131 - } 132 - 133 - // If we want to echo stuff, we read directly from stdin 134 - @@ -130,7 +131,7 @@ func ReadPassphrase(prompt string, flags ReadPassFlags) ([]byte, error) { 135 - return pin, nil 136 - } 137 - 138 - -func SshAskPass(prompt, hint string) ([]byte, error) { 139 - +func SshAskPass(prompt, hint string, clientPID int) ([]byte, error) { 140 - var askpass string 141 - var err error 142 - if s, ok := os.LookupEnv("SSH_ASKPASS"); ok { 143 - @@ -144,7 +145,15 @@ func SshAskPass(prompt, hint string) ([]byte, error) { 144 - } 145 - } 146 - 147 - + if clientPID > 0 && os.Getenv("SSH_CLIENT_PID") == "" { 148 - + // Pass the requesting client's PID to pinentry so it can cache per-client. 149 - + os.Setenv("SSH_CLIENT_PID", strconv.Itoa(clientPID)) 150 - + } 151 - + 152 - cmd := exec.Command(askpass, prompt) 153 - + if clientPID > 0 { 154 - + cmd.Env = append(os.Environ(), "SSH_CLIENT_PID="+strconv.Itoa(clientPID)) 155 - + } 156 - if hint != "" { 157 - // Set SSH_ASKPASS_PROMPT only on the subprocess. os.Setenv() would 158 - // leak into subsequent passphrase prompts from the same agent 159 - @@ -173,8 +182,8 @@ func SshAskPass(prompt, hint string) ([]byte, error) { 160 - 161 - // AskPermission runs SSH_ASKPASS with SSH_ASKPASS_PROMPT=confirm set as env. 162 - // It will expect exit code 0 or !0 and return true and false respectively. 163 - -func AskPermission(prompt string) (bool, error) { 164 - - a, err := ReadPassphrase(prompt, RP_USE_ASKPASS|RP_ASK_PERMISSION) 165 - +func AskPermission(prompt string, clientPID int) (bool, error) { 166 - + a, err := ReadPassphrase(prompt, RP_USE_ASKPASS|RP_ASK_PERMISSION, clientPID) 167 - if err != nil { 168 - return false, err 169 - } 170 - diff --git a/cmd/ssh-tpm-agent/main.go b/cmd/ssh-tpm-agent/main.go 171 - index 7c6f397..8e19473 100644 172 - --- a/cmd/ssh-tpm-agent/main.go 173 - +++ b/cmd/ssh-tpm-agent/main.go 174 - @@ -243,7 +243,7 @@ func main() { 175 - // Owner password 176 - func() ([]byte, error) { 177 - if askOwnerPassword { 178 - - return askpass.ReadPassphrase("Enter owner password for TPM", askpass.RP_USE_ASKPASS) 179 - + return askpass.ReadPassphrase("Enter owner password for TPM", askpass.RP_USE_ASKPASS, 0) 180 - } else { 181 - ownerPassword := os.Getenv("SSH_TPM_AGENT_OWNER_PASSWORD") 182 - return []byte(ownerPassword), nil 183 - @@ -253,7 +253,7 @@ func main() { 184 - // PIN Callback with caching 185 - // SSHKeySigner in signer/signer.go resets this value if 186 - // we get a TPMRCAuthFail 187 - - func(key key.SSHTPMKeys) ([]byte, error) { 188 - + func(key key.SSHTPMKeys, clientPID int) ([]byte, error) { 189 - auth, err := agentkeyring.ReadKey(key.Fingerprint()) 190 - switch { 191 - case errors.Is(err, syscall.ENOENT): 192 - @@ -262,7 +262,7 @@ func main() { 193 - case errors.Is(err, syscall.ENOKEY) || errors.Is(err, syscall.EACCES): 194 - keyInfo := fmt.Sprintf("Enter passphrase for (%s): ", key.GetDescription()) 195 - // TODO: askpass should box the byte slice 196 - - userauth, err := askpass.ReadPassphrase(keyInfo, askpass.RP_USE_ASKPASS) 197 - + userauth, err := askpass.ReadPassphrase(keyInfo, askpass.RP_USE_ASKPASS, clientPID) 198 - fmt.Println(err) 199 - if !noCache && err == nil { 200 - slog.Debug("caching userauth for key in keyring", slog.String("fp", key.Fingerprint())) 201 - diff --git a/cmd/ssh-tpm-agent/main_test.go b/cmd/ssh-tpm-agent/main_test.go 202 - index 8335058..89697f2 100644 203 - --- a/cmd/ssh-tpm-agent/main_test.go 204 - +++ b/cmd/ssh-tpm-agent/main_test.go 205 - @@ -144,7 +144,7 @@ func runSSHAuth(t *testing.T, keytype tpm2.TPMAlgID, bits int, pin []byte, keyfn 206 - // Owner password 207 - func() ([]byte, error) { return []byte(""), nil }, 208 - // PIN Callback 209 - - func(_ key.SSHTPMKeys) ([]byte, error) { 210 - + func(_ key.SSHTPMKeys, _ int) ([]byte, error) { 211 - return pin, nil 212 - }, 213 - ) 214 - diff --git a/cmd/ssh-tpm-keygen/main.go b/cmd/ssh-tpm-keygen/main.go 215 - index 4002026..eae8d39 100644 216 - --- a/cmd/ssh-tpm-keygen/main.go 217 - +++ b/cmd/ssh-tpm-keygen/main.go 218 - @@ -89,11 +89,11 @@ Example: 219 - 220 - func getPin() ([]byte, error) { 221 - for { 222 - - pin1, err := askpass.ReadPassphrase("Enter passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 223 - + pin1, err := askpass.ReadPassphrase("Enter passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 224 - if err != nil { 225 - return nil, err 226 - } 227 - - pin2, err := askpass.ReadPassphrase("Enter same passphrase again: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 228 - + pin2, err := askpass.ReadPassphrase("Enter same passphrase again: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 229 - if err != nil { 230 - return nil, err 231 - } 232 - @@ -106,7 +106,7 @@ func getPin() ([]byte, error) { 233 - } 234 - 235 - func getOwnerPassword() ([]byte, error) { 236 - - return askpass.ReadPassphrase("Enter owner password: ", askpass.RP_ALLOW_STDIN) 237 - + return askpass.ReadPassphrase("Enter owner password: ", askpass.RP_ALLOW_STDIN, 0) 238 - } 239 - 240 - func doHostKeys(tpm transport.TPMCloser, outputFile string, ownerPassword []byte, hierarchy string) { 241 - @@ -191,7 +191,7 @@ func doChangePin(tpm transport.TPMCloser, passphrase, keyPin, ownerPassword []by 242 - fmt.Printf("Key has comment '%s'\n", k.Description) 243 - } 244 - if outputFile == "" { 245 - - f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which the key is (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 246 - + f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which the key is (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON, 0) 247 - if err != nil { 248 - return err 249 - } 250 - @@ -202,18 +202,18 @@ func doChangePin(tpm transport.TPMCloser, passphrase, keyPin, ownerPassword []by 251 - } 252 - 253 - if len(passphrase) == 0 { 254 - - passphrase, err = askpass.ReadPassphrase("Enter old passphrase: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 255 - + passphrase, err = askpass.ReadPassphrase("Enter old passphrase: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 256 - if err != nil { 257 - return err 258 - } 259 - } 260 - 261 - if len(keyPin) == 0 { 262 - - keyPin, err = askpass.ReadPassphrase("Enter new passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 263 - + keyPin, err = askpass.ReadPassphrase("Enter new passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 264 - if err != nil { 265 - return err 266 - } 267 - - newPin, err := askpass.ReadPassphrase("Enter same passphrase: ", askpass.RP_ALLOW_STDIN) 268 - + newPin, err := askpass.ReadPassphrase("Enter same passphrase: ", askpass.RP_ALLOW_STDIN, 0) 269 - if err != nil { 270 - return err 271 - } 272 - @@ -260,7 +260,7 @@ func doWrapWith(supportedECCBitsizes []int, wrap, wrapWith string, keyParentHand 273 - rawKey, err = ssh.ParseRawPrivateKey(pem) 274 - if errors.As(err, &kerr) { 275 - for { 276 - - pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 277 - + pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 278 - if err != nil { 279 - log.Fatal(err) 280 - } 281 - @@ -358,7 +358,7 @@ func supportedECCBitsizes(tpm transport.TPMCloser) []int { 282 - func checkFile(f string) error { 283 - if utils.FileExists(f) { 284 - fmt.Printf("%s already exists.\n", f) 285 - - s, err := askpass.ReadPassphrase("Overwrite (y/n)? ", askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 286 - + s, err := askpass.ReadPassphrase("Overwrite (y/n)? ", askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON, 0) 287 - if err != nil { 288 - return err 289 - } 290 - @@ -378,7 +378,7 @@ func doImportKey(tpm transport.TPMCloser, keyParentHandle tpm2.TPMHandle, ownerP 291 - rawKey, err = ssh.ParseRawPrivateKey(pem) 292 - if errors.As(err, &kerr) { 293 - for { 294 - - pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 295 - + pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 296 - if err != nil { 297 - return nil, err 298 - } 299 - @@ -670,7 +670,7 @@ func main() { 300 - // Else create a normal key 301 - fmt.Printf("Generating a sealed public/private %s key pair.\n", keyType) 302 - if outputFile == "" { 303 - - f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which to save the key (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 304 - + f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which to save the key (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON, 0) 305 - if err != nil { 306 - log.Fatal(err) 307 - } 308 - diff --git a/internal/keytest/keytest.go b/internal/keytest/keytest.go 309 - index ade4ad8..90fcf77 100644 310 - --- a/internal/keytest/keytest.go 311 - +++ b/internal/keytest/keytest.go 312 - @@ -121,7 +121,7 @@ func NewTestAgent(t *testing.T, tpm transport.TPMCloser) *agent.Agent { 313 - func() *keyring.ThreadKeyring { return &keyring.ThreadKeyring{} }, 314 - func() transport.TPMCloser { return tpm }, 315 - func() ([]byte, error) { return []byte(""), nil }, 316 - - func(_ key.SSHTPMKeys) ([]byte, error) { 317 - + func(_ key.SSHTPMKeys, _ int) ([]byte, error) { 318 - return []byte(""), nil 319 - }, 320 - )
-10
5pkgs/ssh-tpm-agent/package.nix
··· 1 - { 2 - ssh-tpm-agent, 3 - }: 4 - 5 - ssh-tpm-agent.overrideAttrs (old: { 6 - patches = (old.patches or [ ]) ++ [ 7 - ./client-pid.patch 8 - ]; 9 - doCheck = false; 10 - })
-39
5pkgs/sshify/package.nix
··· 1 - { pkgs }: 2 - # usage: sshify neoprism.r -- curl ifconfig.me 3 - pkgs.writers.writeBashBin "sshify" '' 4 - set -efu 5 - 6 - TMPDIR=$(mktemp -d) 7 - 8 - SSH_ARGS=() 9 - 10 - while [[ "$#" -gt 0 ]]; do 11 - case $1 in 12 - --) 13 - shift 14 - break 15 - ;; 16 - *) 17 - SSH_ARGS+=($1) 18 - ;; 19 - esac 20 - shift 21 - done 22 - 23 - if [[ "$#" -le 0 ]]; then 24 - echo no command specified 25 - exit 1 26 - fi 27 - 28 - RANDOM_HIGH_PORT=$(shuf -i 20000-65000 -n 1) 29 - 30 - cat << EOF >$TMPDIR/proxychains.conf 31 - [ProxyList] 32 - socks4 127.0.0.1 $RANDOM_HIGH_PORT 33 - EOF 34 - 35 - ssh -fNM -S "$TMPDIR/socket" -D "$RANDOM_HIGH_PORT" "''${SSH_ARGS[@]}" 36 - trap "ssh -S $TMPDIR/socket -O exit bla 2>/dev/null; rm -rf $TMPDIR >&2" EXIT 37 - 38 - ${pkgs.proxychains-ng}/bin/proxychains4 -q -f "$TMPDIR/proxychains.conf" "$@" 39 - ''
-27
5pkgs/sxiv/package.nix
··· 1 - { nsxiv, writers }: 2 - 3 - writers.writeDashBin "sxiv" '' 4 - set -efu 5 - tmpfile="''${TMPDIR:-/tmp}/nsxiv_pipe_$$" 6 - trap 'rm -f -- $tmpfile' EXIT 7 - 8 - if [ "$#" -eq 0 ]; then 9 - if [ -t 0 ]; then 10 - echo "sxiv: No arguments provided" >&2; exit 1 11 - else 12 - # Consume stdin and put it in the temporal file 13 - cat > "$tmpfile" 14 - fi 15 - fi 16 - 17 - for arg in "$@"; do 18 - # if it's a pipe then drain it to $tmpfile 19 - [ -p "$arg" ] && cat "$arg" > "$tmpfile" 20 - done 21 - 22 - if [ -s "$tmpfile" ]; then 23 - ${nsxiv}/bin/nsxiv -q "$@" "$tmpfile" # -q to silence warnings 24 - else 25 - ${nsxiv}/bin/nsxiv "$@" # fallback 26 - fi 27 - ''
-57
5pkgs/system-audio-dump/package.nix
··· 1 - { 2 - lib, 3 - fetchFromGitHub, 4 - swift, 5 - swiftpm, 6 - swiftPackages, 7 - }: 8 - 9 - swiftPackages.stdenv.mkDerivation { 10 - pname = "system-audio-dump"; 11 - version = "0-unstable-2025-09-02"; 12 - 13 - src = fetchFromGitHub { 14 - owner = "sohzm"; 15 - repo = "systemAudioDump"; 16 - rev = "19caa4f6c0661c03a10d1f08c79a11f0b00f251a"; 17 - hash = "sha256-H2TeB7/Meq7EvfW7zLZn5GGFrKwCyZuJvafv61ZWdrA="; 18 - }; 19 - 20 - nativeBuildInputs = [ 21 - swift 22 - swiftpm 23 - ]; 24 - 25 - postPatch = '' 26 - # Downgrade swift-tools-version from 6.0 to 5.10 and macOS from v15 to v14 27 - sed -i 's/swift-tools-version:6.0/swift-tools-version:5.10/' Package.swift 28 - sed -i 's/.macOS(.v15)/.macOS(.v14)/' Package.swift 29 - 30 - # Patch Swift 6 / macOS 15 features in source 31 - # Remove @retroactive attribute (Swift 6 feature for silencing conformance warnings) 32 - sed -i 's/@retroactive //' Sources/SystemAudioDump/main.swift 33 - # Remove captureMicrophone (macOS 15+ only, defaults to false anyway) 34 - sed -i '/captureMicrophone/d' Sources/SystemAudioDump/main.swift 35 - ''; 36 - 37 - buildPhase = '' 38 - runHook preBuild 39 - swift build -c release 40 - runHook postBuild 41 - ''; 42 - 43 - installPhase = '' 44 - runHook preInstall 45 - mkdir -p $out/bin 46 - cp .build/release/SystemAudioDump $out/bin/system-audio-dump 47 - runHook postInstall 48 - ''; 49 - 50 - meta = { 51 - description = "macOS CLI that captures system audio and outputs raw PCM to stdout"; 52 - homepage = "https://github.com/sohzm/systemAudioDump"; 53 - license = lib.licenses.mit; 54 - platforms = lib.platforms.darwin; 55 - mainProgram = "system-audio-dump"; 56 - }; 57 - }
-73
5pkgs/timeful/package.nix
··· 1 - { 2 - lib, 3 - applyPatches, 4 - buildGoModule, 5 - buildNpmPackage, 6 - fetchFromGitHub, 7 - makeWrapper, 8 - }: 9 - 10 - let 11 - version = "0-unstable-2026-10-01"; 12 - 13 - # self-host.patch (AGPL-3.0 §13: this directory is the corresponding source 14 - # linked from the footer and the landing page): 15 - # - strips Google Tag Manager, Publift/Primis ads, the paywall and the 16 - # landing page's YouTube/Vimeo embeds and GitHub star button 17 - # - replaces the Google/Outlook/email-OTP sign-in page with GitHub OAuth 18 - # (GET /api/auth/github, GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET) 19 - # - "Schedule" offers a .ics download next to Google Calendar/Outlook, and 20 - # scheduled-event links point at this instance instead of timeful.app 21 - # - makes the listen address configurable via LISTEN_ADDR (upstream 22 - # hardcodes ":3002" on all interfaces) 23 - src = applyPatches { 24 - src = fetchFromGitHub { 25 - owner = "schej-it"; 26 - repo = "timeful.app"; 27 - rev = "23b7e7ead1d279ea5d8f431199a230c2dfbe0c1a"; 28 - hash = "sha256-Yp2dkz4+QZ9pqZXOM4srZsCiu/TGLQXGNZtsRd5Dx+U="; 29 - }; 30 - patches = [ ./self-host.patch ]; 31 - }; 32 - 33 - frontend = buildNpmPackage { 34 - pname = "timeful-frontend"; 35 - inherit version; 36 - src = "${src}/frontend"; 37 - 38 - npmDepsHash = "sha256-eHitoSJUPX2mhlOgGrECCUp0zhKFxgsfxkY7+BmeHak="; 39 - 40 - # No PostHog key and no Google/Microsoft OAuth client baked in; sign-in 41 - # goes through the server-side GitHub flow instead. 42 - installPhase = '' 43 - runHook preInstall 44 - cp -r dist $out 45 - runHook postInstall 46 - ''; 47 - }; 48 - in 49 - buildGoModule { 50 - pname = "timeful"; 51 - inherit version src; 52 - 53 - modRoot = "server"; 54 - subPackages = [ "." ]; 55 - vendorHash = "sha256-dl9JURh6pCB7P+jAWcjlPZwmkKPqV7EaOJZSwJYwcFQ="; 56 - 57 - nativeBuildInputs = [ makeWrapper ]; 58 - 59 - postInstall = '' 60 - mv $out/bin/server $out/bin/timeful 61 - wrapProgram $out/bin/timeful --set-default FRONTEND_DIST ${frontend} 62 - ''; 63 - 64 - passthru = { inherit frontend; }; 65 - 66 - meta = { 67 - description = "Availability poll to find the best time for a group to meet (formerly Schej)"; 68 - homepage = "https://github.com/schej-it/timeful.app"; 69 - license = lib.licenses.agpl3Only; 70 - mainProgram = "timeful"; 71 - platforms = lib.platforms.linux; 72 - }; 73 - }
-898
5pkgs/timeful/self-host.patch
··· 1 - diff --git a/frontend/public/index.html b/frontend/public/index.html 2 - index a300a8a4..48cb0e61 100644 3 - --- a/frontend/public/index.html 4 - +++ b/frontend/public/index.html 5 - @@ -1,21 +1,6 @@ 6 - <!DOCTYPE html> 7 - <html lang="en"> 8 - <head> 9 - - <!-- Google Tag Manager --> 10 - - <script> 11 - - ;(function (w, d, s, l, i) { 12 - - w[l] = w[l] || [] 13 - - w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" }) 14 - - var f = d.getElementsByTagName(s)[0], 15 - - j = d.createElement(s), 16 - - dl = l != "dataLayer" ? "&l=" + l : "" 17 - - j.async = true 18 - - j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl 19 - - f.parentNode.insertBefore(j, f) 20 - - })(window, document, "script", "dataLayer", "GTM-M677X6V") 21 - - </script> 22 - - <!-- End Google Tag Manager --> 23 - - <script async src="https://cdn.fuseplatform.net/publift/tags/2/4241/fuse.js"></script> 24 - 25 - <meta charset="utf-8" /> 26 - <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 27 - @@ -59,16 +44,6 @@ 28 - /> 29 - </head> 30 - <body> 31 - - <!-- Google Tag Manager (noscript) --> 32 - - <noscript 33 - - ><iframe 34 - - src="https://www.googletagmanager.com/ns.html?id=GTM-M677X6V" 35 - - height="0" 36 - - width="0" 37 - - style="display: none; visibility: hidden" 38 - - ></iframe 39 - - ></noscript> 40 - - <!-- End Google Tag Manager (noscript) --> 41 - <noscript> 42 - <strong 43 - >We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work 44 - diff --git a/frontend/src/App.vue b/frontend/src/App.vue 45 - index 176178b5..01469b17 100644 46 - --- a/frontend/src/App.vue 47 - +++ b/frontend/src/App.vue 48 - @@ -357,7 +357,10 @@ export default { 49 - this.webviewDialog = true 50 - return 51 - } 52 - - this.$router.push({ name: "sign-in" }) 53 - + this.$router.push({ 54 - + name: "sign-in", 55 - + query: { next: this.$route.fullPath }, 56 - + }) 57 - // this.signInDialog = true 58 - } else { 59 - this.$router.push({ name: "sign-in" }) 60 - diff --git a/frontend/src/components/Footer.vue b/frontend/src/components/Footer.vue 61 - index 35d9a6d5..7e8815d4 100644 62 - --- a/frontend/src/components/Footer.vue 63 - +++ b/frontend/src/components/Footer.vue 64 - @@ -30,7 +30,7 @@ 65 - </svg> 66 - </a> 67 - <a 68 - - href="https://github.com/schej-it/timeful.app" 69 - + href="https://github.com/Lassulus/superconfig/tree/master/5pkgs/timeful" 70 - aria-label="GitHub" 71 - target="_blank" 72 - ><v-icon class="tw-text-white/80 hover:tw-text-white" 73 - diff --git a/frontend/src/components/home/Dashboard.vue b/frontend/src/components/home/Dashboard.vue 74 - index c56eebff..af75f247 100644 75 - --- a/frontend/src/components/home/Dashboard.vue 76 - +++ b/frontend/src/components/home/Dashboard.vue 77 - @@ -8,7 +8,7 @@ 78 - Dashboard 79 - </div> 80 - <div 81 - - v-if="!isPremiumUser" 82 - + v-if="enablePaywall && !isPremiumUser" 83 - class="tw-flex tw-items-baseline tw-gap-2 tw-text-sm tw-font-normal tw-text-very-dark-gray" 84 - > 85 - <div> 86 - @@ -240,7 +240,13 @@ export default { 87 - }, 88 - computed: { 89 - ...mapGetters(["isPremiumUser"]), 90 - - ...mapState(["authUser", "events", "groupsEnabled", "folders"]), 91 - + ...mapState([ 92 - + "authUser", 93 - + "events", 94 - + "groupsEnabled", 95 - + "folders", 96 - + "enablePaywall", 97 - + ]), 98 - orderedFolders() { 99 - return this.folders.sort((a, b) => { 100 - return a.name.localeCompare(b.name) 101 - diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 102 - index 1e028082..d7377fa3 100644 103 - --- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 104 - +++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 105 - @@ -1048,6 +1048,7 @@ import { 106 - isPremiumUser, 107 - prefersStartOnMonday, 108 - } from "@/utils" 109 - +import { buildIcs, downloadIcs } from "@/utils/ics_utils" 110 - import { 111 - availabilityTypes, 112 - calendarOptionsDefaults, 113 - @@ -1250,11 +1251,7 @@ export default { 114 - ...mapState(["authUser", "overlayAvailabilitiesEnabled"]), 115 - ...mapGetters(["isPremiumUser"]), 116 - showAds() { 117 - - return ( 118 - - !this.ownerIsPremium && 119 - - !this.isPremiumUser && 120 - - this.state !== this.states.SET_SPECIFIC_TIMES 121 - - ) 122 - + return false 123 - }, 124 - /** Returns the width of the right side of the calendar */ 125 - rightSideWidth() { 126 - @@ -2076,7 +2073,7 @@ export default { 127 - } 128 - return `Tap and drag to add your "available" ${daysOrTimes} in green.` 129 - case this.states.SCHEDULE_EVENT: 130 - - return "Tap and drag on the calendar to schedule a Google Calendar event during those times." 131 - + return "Tap and drag on the calendar to schedule an event during those times." 132 - default: 133 - return "" 134 - } 135 - @@ -2092,7 +2089,7 @@ export default { 136 - } 137 - return `Click and drag to add your "available" ${daysOrTimes} in green.` 138 - case this.states.SCHEDULE_EVENT: 139 - - return "Click and drag on the calendar to schedule a Google Calendar event during those times." 140 - + return "Click and drag on the calendar to schedule an event during those times." 141 - default: 142 - return "" 143 - } 144 - @@ -3563,8 +3560,11 @@ export default { 145 - this.state = this.defaultState 146 - }, 147 - 148 - - /** Redirect user to Google Calendar to finish the creation of the event */ 149 - - confirmScheduleEvent(googleCalendar = true) { 150 - + /** 151 - + * Finish scheduling the dragged-out slot: open Google Calendar / Outlook 152 - + * prefilled, or download an .ics file. target: "google" | "outlook" | "ics" 153 - + */ 154 - + confirmScheduleEvent(target = "google") { 155 - if (!this.curScheduledEvent) return 156 - // if (!isPremiumUser(this.authUser)) { 157 - // this.showUpgradeDialog({ 158 - @@ -3615,9 +3615,27 @@ export default { 159 - const emailsString = encodeURIComponent(emails.filter(Boolean).join(",")) 160 - 161 - const eventId = this.event.shortId ?? this.event._id 162 - + const eventUrl = `${window.location.origin}/e/${eventId}` 163 - + const description = `\n\nThis event was scheduled with Timeful: ${eventUrl}` 164 - + 165 - + if (target === "ics") { 166 - + const ics = buildIcs({ 167 - + uid: `${this.event._id}-${startDate.getTime()}@${window.location.host}`, 168 - + start: startDate, 169 - + end: endDate, 170 - + summary: this.event.name, 171 - + description: description.trim(), 172 - + location: this.event.location, 173 - + url: eventUrl, 174 - + }) 175 - + const filename = `${this.event.name.replace(/[^\w-]+/g, "_") || "event"}.ics` 176 - + downloadIcs(filename, ics) 177 - + this.state = this.defaultState 178 - + return 179 - + } 180 - 181 - let url = "" 182 - - if (googleCalendar) { 183 - + if (target === "google") { 184 - // Format start and end date to be in the format required by gcal (remove -, :, and .000) 185 - const start = startDate.toISOString().replace(/([-:]|\.000)/g, "") 186 - const end = endDate.toISOString().replace(/([-:]|\.000)/g, "") 187 - @@ -3626,14 +3644,13 @@ export default { 188 - url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent( 189 - this.event.name 190 - )}&dates=${start}/${end}&details=${encodeURIComponent( 191 - - "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" 192 - - )}${eventId}&ctz=${this.curTimezone.value}&add=${emailsString}` 193 - + description 194 - + )}&ctz=${this.curTimezone.value}&add=${emailsString}` 195 - } else { 196 - url = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent( 197 - this.event.name 198 - )}&body=${encodeURIComponent( 199 - - "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" + 200 - - eventId 201 - + description 202 - )}&startdt=${startDate.toISOString()}&enddt=${endDate.toISOString()}&location=${encodeURIComponent( 203 - this.event.location || "" 204 - )}&path=/calendar/action/compose&timezone=${this.curTimezone.value}` 205 - diff --git a/frontend/src/components/schedule_overlap/ToolRow.vue b/frontend/src/components/schedule_overlap/ToolRow.vue 206 - index d8aaaddd..5822dd28 100644 207 - --- a/frontend/src/components/schedule_overlap/ToolRow.vue 208 - +++ b/frontend/src/components/schedule_overlap/ToolRow.vue 209 - @@ -118,7 +118,9 @@ 210 - </v-btn> 211 - </template> 212 - <v-list dense> 213 - - <v-list-item @click="(e) => $emit('confirmScheduleEvent', true)"> 214 - + <v-list-item 215 - + @click="(e) => $emit('confirmScheduleEvent', 'google')" 216 - + > 217 - <v-img 218 - src="@/assets/gcal_logo.png" 219 - class="tw-mr-2 tw-flex-none" 220 - @@ -129,7 +131,9 @@ 221 - <v-list-item-title>Google Calendar</v-list-item-title> 222 - </v-list-item-content> 223 - </v-list-item> 224 - - <v-list-item @click="(e) => $emit('confirmScheduleEvent', false)"> 225 - + <v-list-item 226 - + @click="(e) => $emit('confirmScheduleEvent', 'outlook')" 227 - + > 228 - <v-img 229 - src="@/assets/outlook_logo.svg" 230 - class="tw-mr-2 tw-flex-none" 231 - @@ -140,6 +144,14 @@ 232 - <v-list-item-title>Outlook</v-list-item-title> 233 - </v-list-item-content> 234 - </v-list-item> 235 - + <v-list-item @click="(e) => $emit('confirmScheduleEvent', 'ics')"> 236 - + <v-icon class="tw-mr-2 tw-flex-none" size="20" 237 - + >mdi-calendar-export</v-icon 238 - + > 239 - + <v-list-item-content> 240 - + <v-list-item-title>Download .ics</v-list-item-title> 241 - + </v-list-item-content> 242 - + </v-list-item> 243 - </v-list> 244 - </v-menu> 245 - </template> 246 - diff --git a/frontend/src/main.js b/frontend/src/main.js 247 - index 2d370f7a..9dfa971a 100644 248 - --- a/frontend/src/main.js 249 - +++ b/frontend/src/main.js 250 - @@ -5,19 +5,12 @@ import router from "./router" 251 - import store from "./store" 252 - import vuetify from "./plugins/vuetify" 253 - import posthogPlugin from "./plugins/posthog" 254 - -import VueGtm from "@gtm-support/vue2-gtm" 255 - import VueMeta from "vue-meta" 256 - import "./index.css" 257 - 258 - // Posthog 259 - Vue.use(posthogPlugin) 260 - 261 - -// Google Analytics 262 - -Vue.use(VueGtm, { 263 - - id: "GTM-M677X6V", 264 - - vueRouter: router, 265 - -}) 266 - - 267 - // Site Metadata 268 - Vue.use(VueMeta) 269 - 270 - diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js 271 - index a0770841..b9460a8f 100644 272 - --- a/frontend/src/store/index.js 273 - +++ b/frontend/src/store/index.js 274 - @@ -29,7 +29,7 @@ export default new Vuex.Store({ 275 - signUpFormEnabled: false, 276 - daysOnlyEnabled: true, 277 - overlayAvailabilitiesEnabled: true, 278 - - enablePaywall: true, 279 - + enablePaywall: false, 280 - 281 - // Experiments 282 - pricingPageConversion: "control", 283 - diff --git a/frontend/src/utils/ics_utils.js b/frontend/src/utils/ics_utils.js 284 - new file mode 100644 285 - index 00000000..c285bd1b 286 - --- /dev/null 287 - +++ b/frontend/src/utils/ics_utils.js 288 - @@ -0,0 +1,69 @@ 289 - +/** Builds and downloads a single-event iCalendar (RFC 5545) file. */ 290 - + 291 - +const utf8 = new TextEncoder() 292 - + 293 - +/** Escapes a TEXT property value (RFC 5545 §3.3.11) */ 294 - +const escapeText = (text) => 295 - + text 296 - + .replace(/\\/g, "\\\\") 297 - + .replace(/;/g, "\\;") 298 - + .replace(/,/g, "\\,") 299 - + .replace(/\r?\n/g, "\\n") 300 - + 301 - +/** Formats a Date as a UTC DATE-TIME, e.g. 20261020T070000Z */ 302 - +const formatDateTime = (date) => 303 - + date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "") 304 - + 305 - +/** Folds a content line so no physical line exceeds 75 octets (RFC 5545 §3.1) */ 306 - +const foldLine = (line) => { 307 - + const parts = [] 308 - + let current = "" 309 - + let currentBytes = 0 310 - + for (const char of line) { 311 - + const charBytes = utf8.encode(char).length 312 - + // continuation lines start with a space, which counts toward the limit 313 - + const limit = parts.length === 0 ? 75 : 74 314 - + if (currentBytes + charBytes > limit) { 315 - + parts.push(current) 316 - + current = "" 317 - + currentBytes = 0 318 - + } 319 - + current += char 320 - + currentBytes += charBytes 321 - + } 322 - + parts.push(current) 323 - + return parts.join("\r\n ") 324 - +} 325 - + 326 - +export const buildIcs = ({ uid, start, end, summary, description, location, url }) => { 327 - + const lines = [ 328 - + "BEGIN:VCALENDAR", 329 - + "VERSION:2.0", 330 - + "PRODID:-//timeful//self-hosted//EN", 331 - + "CALSCALE:GREGORIAN", 332 - + "METHOD:PUBLISH", 333 - + "BEGIN:VEVENT", 334 - + `UID:${uid}`, 335 - + `DTSTAMP:${formatDateTime(new Date())}`, 336 - + `DTSTART:${formatDateTime(start)}`, 337 - + `DTEND:${formatDateTime(end)}`, 338 - + `SUMMARY:${escapeText(summary)}`, 339 - + ] 340 - + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`) 341 - + if (location) lines.push(`LOCATION:${escapeText(location)}`) 342 - + if (url) lines.push(`URL:${url}`) 343 - + lines.push("END:VEVENT", "END:VCALENDAR") 344 - + return lines.map(foldLine).join("\r\n") + "\r\n" 345 - +} 346 - + 347 - +export const downloadIcs = (filename, icsText) => { 348 - + const blob = new Blob([icsText], { type: "text/calendar;charset=utf-8" }) 349 - + const href = URL.createObjectURL(blob) 350 - + const a = document.createElement("a") 351 - + a.href = href 352 - + a.download = filename 353 - + document.body.appendChild(a) 354 - + a.click() 355 - + a.remove() 356 - + URL.revokeObjectURL(href) 357 - +} 358 - diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue 359 - index 81887280..87dba8d1 100644 360 - --- a/frontend/src/views/Event.vue 361 - +++ b/frontend/src/views/Event.vue 362 - @@ -463,13 +463,29 @@ 363 - Cancel 364 - </v-btn> 365 - <v-spacer /> 366 - - <v-btn 367 - - :disabled="!allowScheduleEvent" 368 - - class="tw-bg-white tw-text-blue" 369 - - @click="confirmScheduleEvent" 370 - - > 371 - - Schedule 372 - - </v-btn> 373 - + <v-menu top offset-y> 374 - + <template v-slot:activator="{ on, attrs }"> 375 - + <v-btn 376 - + :disabled="!allowScheduleEvent" 377 - + class="tw-bg-white tw-text-blue" 378 - + v-bind="attrs" 379 - + v-on="on" 380 - + > 381 - + Schedule 382 - + </v-btn> 383 - + </template> 384 - + <v-list dense> 385 - + <v-list-item @click="confirmScheduleEvent('google')"> 386 - + <v-list-item-title>Google Calendar</v-list-item-title> 387 - + </v-list-item> 388 - + <v-list-item @click="confirmScheduleEvent('outlook')"> 389 - + <v-list-item-title>Outlook</v-list-item-title> 390 - + </v-list-item> 391 - + <v-list-item @click="confirmScheduleEvent('ics')"> 392 - + <v-list-item-title>Download .ics</v-list-item-title> 393 - + </v-list-item> 394 - + </v-list> 395 - + </v-menu> 396 - </template> 397 - </div> 398 - <PubliftAd 399 - @@ -638,11 +654,7 @@ export default { 400 - ...mapState(["authUser", "events"]), 401 - ...mapGetters(["isPremiumUser"]), 402 - showAds() { 403 - - return ( 404 - - !this.ownerIsPremium && 405 - - !this.isPremiumUser && 406 - - !this.isSettingSpecificTimes 407 - - ) 408 - + return false 409 - }, 410 - allowScheduleEvent() { 411 - return this.scheduleOverlapComponent?.allowScheduleEvent 412 - @@ -998,8 +1010,8 @@ export default { 413 - cancelScheduleEvent() { 414 - this.scheduleOverlapComponent?.cancelScheduleEvent() 415 - }, 416 - - confirmScheduleEvent() { 417 - - this.scheduleOverlapComponent?.confirmScheduleEvent() 418 - + confirmScheduleEvent(target) { 419 - + this.scheduleOverlapComponent?.confirmScheduleEvent(target) 420 - }, 421 - 422 - highlightAvailabilityBtn() { 423 - diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue 424 - index b8a50d8f..41d7521c 100644 425 - --- a/frontend/src/views/Landing.vue 426 - +++ b/frontend/src/views/Landing.vue 427 - @@ -31,13 +31,10 @@ 428 - class="tw-mb-4 tw-flex tw-select-none tw-items-center tw-rounded-full tw-border tw-border-light-gray-stroke tw-bg-white/70 tw-px-2.5 tw-py-1.5 tw-text-sm tw-text-dark-gray" 429 - > 430 - We're open source! 431 - - <github-button 432 - - v-once 433 - - class="-tw-mb-1 tw-ml-2" 434 - - href="https://github.com/schej-it/timeful.app" 435 - - data-show-count="true" 436 - - aria-label="Star timeful.app on GitHub" 437 - - >Star</github-button 438 - + <a 439 - + class="tw-ml-2 tw-text-blue" 440 - + href="https://github.com/Lassulus/superconfig/tree/master/5pkgs/timeful" 441 - + >Source</a 442 - > 443 - </div> 444 - <div 445 - @@ -111,20 +108,6 @@ 446 - transition="fade-transition" 447 - contain 448 - /> 449 - - <vue-vimeo-player 450 - - video-url="https://player.vimeo.com/video/1083205305?h=d58bef862a" 451 - - :player-width="800" 452 - - :player-height="800" 453 - - :options="{ 454 - - muted: true, 455 - - playsinline: true, 456 - - responsive: true, 457 - - }" 458 - - :controls="false" 459 - - :autoplay="true" 460 - - :loop="true" 461 - - @play="onPlay" 462 - - /> 463 - </div> 464 - </div> 465 - </div> 466 - @@ -168,25 +151,6 @@ 467 - /> 468 - </div> 469 - 470 - - <!-- Video --> 471 - - <div 472 - - class="tw-flex tw-justify-center tw-bg-green tw-px-4 tw-pb-12 tw-pt-24 md:tw-pb-16" 473 - - > 474 - - <div 475 - - class="tw-h-[300px] tw-max-w-3xl tw-flex-1 sm:tw-h-[400px] md:tw-h-[450px]" 476 - - > 477 - - <iframe 478 - - class="tw-h-full tw-w-full" 479 - - src="https://www.youtube.com/embed/vFkBC8BrkOk?si=pF64JAIyDhom_1do" 480 - - title="Timeful demo" 481 - - frameborder="0" 482 - - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 483 - - referrerpolicy="strict-origin-when-cross-origin" 484 - - allowfullscreen 485 - - ></iframe> 486 - - </div> 487 - - </div> 488 - - 489 - <!-- Reddit Testimonials --> 490 - <div class="tw-flex tw-justify-center tw-bg-light-gray tw-py-12"> 491 - <div class="tw-mx-4 tw-max-w-3xl tw-flex-1 sm:tw-mx-16"> 492 - @@ -299,11 +263,9 @@ import NewEvent from "@/components/NewEvent.vue" 493 - import NewDialog from "@/components/NewDialog.vue" 494 - import LandingPageHeader from "@/components/landing/LandingPageHeader.vue" 495 - import Logo from "@/components/Logo.vue" 496 - -import GithubButton from "vue-github-button" 497 - import SignInDialog from "@/components/SignInDialog.vue" 498 - import { calendarTypes } from "@/constants" 499 - import HowItWorksDialog from "@/components/HowItWorksDialog.vue" 500 - -import { vueVimeoPlayer } from "vue-vimeo-player" 501 - import Footer from "@/components/Footer.vue" 502 - import PronunciationMenu from "@/components/PronunciationMenu.vue" 503 - import { mapState, mapMutations } from "vuex" 504 - @@ -325,11 +287,9 @@ export default { 505 - NewEvent, 506 - NewDialog, 507 - LandingPageHeader, 508 - - GithubButton, 509 - Logo, 510 - SignInDialog, 511 - HowItWorksDialog, 512 - - vueVimeoPlayer, 513 - Footer, 514 - PronunciationMenu, 515 - AuthUserMenu, 516 - diff --git a/frontend/src/views/SignIn.vue b/frontend/src/views/SignIn.vue 517 - index d667be55..6be32f29 100644 518 - --- a/frontend/src/views/SignIn.vue 519 - +++ b/frontend/src/views/SignIn.vue 520 - @@ -32,70 +32,20 @@ 521 - </v-card-title> 522 - <v-card-text class="tw-flex tw-flex-col tw-items-center tw-pt-6"> 523 - <div class="tw-mb-4 tw-flex tw-w-full tw-flex-col tw-gap-y-2"> 524 - - <v-btn 525 - - block 526 - - @click="signIn(calendarTypes.GOOGLE)" 527 - - class="tw-bg-white" 528 - - > 529 - + <v-btn block @click="signInGithub" class="tw-bg-white"> 530 - <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 531 - - <v-img 532 - - class="tw-flex-initial" 533 - - width="20" 534 - - height="20" 535 - - src="@/assets/google_logo.svg" 536 - - /> 537 - + <v-icon class="tw-flex-initial" size="20">mdi-github</v-icon> 538 - <v-spacer /> 539 - - {{ isSignUp ? "Sign up with" : "Continue with" }} Google 540 - + {{ isSignUp ? "Sign up with" : "Continue with" }} GitHub 541 - <v-spacer /> 542 - </div> 543 - </v-btn> 544 - - <v-btn 545 - - block 546 - - @click="signIn(calendarTypes.OUTLOOK)" 547 - - class="tw-bg-white" 548 - + <div 549 - + v-if="$route.query.error === 'github'" 550 - + class="tw-text-center tw-text-sm tw-text-red" 551 - > 552 - - <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 553 - - <v-img 554 - - class="tw-flex-initial" 555 - - width="20" 556 - - height="20" 557 - - src="@/assets/outlook_logo.svg" 558 - - /> 559 - - <v-spacer /> 560 - - {{ isSignUp ? "Sign up with" : "Continue with" }} Outlook 561 - - <v-spacer /> 562 - - </div> 563 - - </v-btn> 564 - - 565 - - <div class="tw-my-2 tw-flex tw-items-center tw-gap-3"> 566 - - <v-divider /> 567 - - <span class="tw-text-xs tw-text-gray">or</span> 568 - - <v-divider /> 569 - - </div> 570 - - 571 - - <div> 572 - - <div class="tw-mb-1 tw-text-sm tw-font-medium"> 573 - - Email address 574 - - </div> 575 - - <v-text-field 576 - - v-model="email" 577 - - class="tw-mb-2" 578 - - placeholder="Enter your email..." 579 - - type="email" 580 - - solo 581 - - hide-details="auto" 582 - - :error-messages="emailError" 583 - - @keydown.enter="submitEmail" 584 - - /> 585 - - <v-btn 586 - - block 587 - - color="primary" 588 - - :loading="sending" 589 - - :disabled="sending" 590 - - @click="submitEmail" 591 - - > 592 - - {{ isSignUp ? "Sign up with" : "Continue with" }} Email 593 - - </v-btn> 594 - + GitHub sign-in failed. Make sure your GitHub account has a 595 - + verified email address and try again. 596 - </div> 597 - </div> 598 - <div class="tw-text-center tw-text-xs"> 599 - @@ -251,7 +201,7 @@ 600 - 601 - <script> 602 - import { authTypes, calendarTypes } from "@/constants" 603 - -import { post, signInGoogle, signInOutlook } from "@/utils" 604 - +import { getEventsCreated, post, signInGoogle, signInOutlook } from "@/utils" 605 - import { mapMutations } from "vuex" 606 - import Logo from "@/components/Logo.vue" 607 - 608 - @@ -299,6 +249,15 @@ export default { 609 - 610 - methods: { 611 - ...mapMutations(["setAuthUser"]), 612 - + signInGithub() { 613 - + const params = new URLSearchParams({ 614 - + timezoneOffset: new Date().getTimezoneOffset(), 615 - + eventsToLink: getEventsCreated().join(","), 616 - + }) 617 - + const next = this.$route.query.next 618 - + if (typeof next === "string") params.set("redirect", next) 619 - + window.location.href = `/api/auth/github?${params}` 620 - + }, 621 - signIn(provider) { 622 - const state = this.upgradeRedirect 623 - ? { type: authTypes.UPGRADE, upgradeParams: this.$route.query.upgradeParams } 624 - diff --git a/server/main.go b/server/main.go 625 - index b038861e..1678b7df 100644 626 - --- a/server/main.go 627 - +++ b/server/main.go 628 - @@ -175,7 +175,9 @@ func main() { 629 - router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 630 - 631 - // Run server 632 - - if os.Getenv("NODE_ENV") == "staging" { 633 - + if listenAddr := os.Getenv("LISTEN_ADDR"); listenAddr != "" { 634 - + router.Run(listenAddr) 635 - + } else if os.Getenv("NODE_ENV") == "staging" { 636 - router.Run(":3003") 637 - } else { 638 - router.Run(":3002") 639 - diff --git a/server/routes/auth.go b/server/routes/auth.go 640 - index 6444e714..2869116d 100644 641 - --- a/server/routes/auth.go 642 - +++ b/server/routes/auth.go 643 - @@ -41,6 +41,8 @@ func InitAuth(router *gin.RouterGroup) { 644 - authRouter.POST("/otp/check-email", checkEmail) 645 - authRouter.POST("/otp/send", sendOtp) 646 - authRouter.POST("/otp/verify", verifyOtp) 647 - + 648 - + initGithubAuth(authRouter) 649 - } 650 - 651 - // @Summary Signs user in 652 - diff --git a/server/routes/github_auth.go b/server/routes/github_auth.go 653 - new file mode 100644 654 - index 00000000..40ef9206 655 - --- /dev/null 656 - +++ b/server/routes/github_auth.go 657 - @@ -0,0 +1,241 @@ 658 - +/* GitHub OAuth sign-in (self-host patch). Accounts are keyed by the GitHub 659 - + * account's primary verified email, like the email OTP flow, and carry no 660 - + * calendar access. */ 661 - +package routes 662 - + 663 - +import ( 664 - + "context" 665 - + "crypto/rand" 666 - + "encoding/hex" 667 - + "encoding/json" 668 - + "fmt" 669 - + "net/http" 670 - + "net/url" 671 - + "os" 672 - + "strconv" 673 - + "strings" 674 - + "time" 675 - + 676 - + "github.com/gin-contrib/sessions" 677 - + "github.com/gin-gonic/gin" 678 - + "go.mongodb.org/mongo-driver/bson" 679 - + "go.mongodb.org/mongo-driver/bson/primitive" 680 - + "schej.it/server/db" 681 - + "schej.it/server/logger" 682 - + "schej.it/server/models" 683 - + "schej.it/server/utils" 684 - +) 685 - + 686 - +var githubHttpClient = &http.Client{Timeout: 10 * time.Second} 687 - + 688 - +// Session keys holding the in-flight sign-in between the redirect to GitHub 689 - +// and the callback. 690 - +const ( 691 - + githubStateKey = "githubState" 692 - + githubRedirectKey = "githubRedirect" 693 - + githubTzKey = "githubTimezoneOffset" 694 - + githubLinkKey = "githubEventsToLink" 695 - +) 696 - + 697 - +func initGithubAuth(authRouter *gin.RouterGroup) { 698 - + authRouter.GET("/github", githubSignIn) 699 - + authRouter.GET("/github/callback", githubCallback) 700 - +} 701 - + 702 - +// @Summary Starts GitHub sign-in 703 - +// @Description Redirects to GitHub's OAuth consent page. Query: redirect (relative path to return to), timezoneOffset, eventsToLink (comma-separated event ids created while signed out) 704 - +// @Tags auth 705 - +// @Router /auth/github [get] 706 - +func githubSignIn(c *gin.Context) { 707 - + clientId := os.Getenv("GITHUB_CLIENT_ID") 708 - + if clientId == "" { 709 - + c.String(http.StatusNotFound, "GitHub sign-in is not configured") 710 - + return 711 - + } 712 - + 713 - + stateBytes := make([]byte, 32) 714 - + if _, err := rand.Read(stateBytes); err != nil { 715 - + logger.StdErr.Panicln(err) 716 - + } 717 - + state := hex.EncodeToString(stateBytes) 718 - + 719 - + session := sessions.Default(c) 720 - + session.Set(githubStateKey, state) 721 - + session.Set(githubRedirectKey, safeRedirectPath(c.Query("redirect"))) 722 - + session.Set(githubTzKey, c.Query("timezoneOffset")) 723 - + session.Set(githubLinkKey, c.Query("eventsToLink")) 724 - + session.Save() 725 - + 726 - + params := url.Values{} 727 - + params.Set("client_id", clientId) 728 - + params.Set("scope", "read:user user:email") 729 - + params.Set("state", state) 730 - + c.Redirect(http.StatusFound, "https://github.com/login/oauth/authorize?"+params.Encode()) 731 - +} 732 - + 733 - +// @Summary GitHub OAuth callback 734 - +// @Description Exchanges the code, finds or creates the user by primary verified email, sets the session and redirects back into the app 735 - +// @Tags auth 736 - +// @Router /auth/github/callback [get] 737 - +func githubCallback(c *gin.Context) { 738 - + session := sessions.Default(c) 739 - + expectedState, _ := session.Get(githubStateKey).(string) 740 - + redirect, _ := session.Get(githubRedirectKey).(string) 741 - + tzString, _ := session.Get(githubTzKey).(string) 742 - + eventsToLink, _ := session.Get(githubLinkKey).(string) 743 - + // The state is single-use: every exit path below saves the session with 744 - + // these keys removed. Each Save emits a Set-Cookie, so save exactly once. 745 - + session.Delete(githubStateKey) 746 - + session.Delete(githubRedirectKey) 747 - + session.Delete(githubTzKey) 748 - + session.Delete(githubLinkKey) 749 - + 750 - + fail := func(reason string, err error) { 751 - + logger.StdErr.Printf("GitHub sign-in failed: %s: %v", reason, err) 752 - + session.Save() 753 - + c.Redirect(http.StatusFound, "/sign-in?error=github") 754 - + } 755 - + 756 - + if expectedState == "" || c.Query("state") != expectedState { 757 - + fail("state mismatch", nil) 758 - + return 759 - + } 760 - + if c.Query("error") != "" { 761 - + fail("denied", fmt.Errorf("%s", c.Query("error"))) 762 - + return 763 - + } 764 - + 765 - + accessToken, err := githubExchangeCode(c.Query("code")) 766 - + if err != nil { 767 - + fail("code exchange", err) 768 - + return 769 - + } 770 - + 771 - + var ghUser struct { 772 - + Login string `json:"login"` 773 - + Name string `json:"name"` 774 - + AvatarUrl string `json:"avatar_url"` 775 - + } 776 - + if err := githubGet(accessToken, "/user", &ghUser); err != nil { 777 - + fail("fetch user", err) 778 - + return 779 - + } 780 - + 781 - + var ghEmails []struct { 782 - + Email string `json:"email"` 783 - + Primary bool `json:"primary"` 784 - + Verified bool `json:"verified"` 785 - + } 786 - + if err := githubGet(accessToken, "/user/emails", &ghEmails); err != nil { 787 - + fail("fetch emails", err) 788 - + return 789 - + } 790 - + email := "" 791 - + for _, e := range ghEmails { 792 - + if e.Verified && (e.Primary || email == "") { 793 - + email = e.Email 794 - + } 795 - + } 796 - + if email == "" { 797 - + fail("no verified email", nil) 798 - + return 799 - + } 800 - + email = utils.NormalizeEmail(email) 801 - + 802 - + var userId primitive.ObjectID 803 - + if existing := db.GetUserByEmail(email); existing != nil { 804 - + userId = existing.Id 805 - + } else { 806 - + firstName, lastName := ghUser.Login, "" 807 - + if name := strings.TrimSpace(ghUser.Name); name != "" { 808 - + firstName, lastName, _ = strings.Cut(name, " ") 809 - + } 810 - + timezoneOffset, _ := strconv.Atoi(tzString) 811 - + 812 - + res, err := db.UsersCollection.InsertOne(context.Background(), models.User{ 813 - + Email: email, 814 - + FirstName: firstName, 815 - + LastName: strings.TrimSpace(lastName), 816 - + Picture: ghUser.AvatarUrl, 817 - + TimezoneOffset: timezoneOffset, 818 - + TokenOrigin: models.WEB, 819 - + }) 820 - + if err != nil { 821 - + logger.StdErr.Panicln(err) 822 - + } 823 - + userId = res.InsertedID.(primitive.ObjectID) 824 - + } 825 - + 826 - + // Claim events this browser created while signed out (same as /auth/sign-in) 827 - + for _, eventIdString := range strings.Split(eventsToLink, ",") { 828 - + eventId, err := primitive.ObjectIDFromHex(eventIdString) 829 - + if err == nil { 830 - + db.EventsCollection.UpdateOne(context.Background(), bson.M{"_id": eventId, "ownerId": nil}, bson.M{"$set": bson.M{"ownerId": userId}}) 831 - + } 832 - + } 833 - + 834 - + session.Set("userId", userId.Hex()) 835 - + session.Save() 836 - + 837 - + c.Redirect(http.StatusFound, redirect) 838 - +} 839 - + 840 - +// safeRedirectPath only allows same-origin absolute paths, so the callback 841 - +// cannot be turned into an open redirect. 842 - +func safeRedirectPath(path string) string { 843 - + if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.HasPrefix(path, "/\\") { 844 - + return "/home" 845 - + } 846 - + return path 847 - +} 848 - + 849 - +func githubExchangeCode(code string) (string, error) { 850 - + form := url.Values{} 851 - + form.Set("client_id", os.Getenv("GITHUB_CLIENT_ID")) 852 - + form.Set("client_secret", os.Getenv("GITHUB_CLIENT_SECRET")) 853 - + form.Set("code", code) 854 - + 855 - + req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode())) 856 - + if err != nil { 857 - + return "", err 858 - + } 859 - + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 860 - + req.Header.Set("Accept", "application/json") 861 - + 862 - + resp, err := githubHttpClient.Do(req) 863 - + if err != nil { 864 - + return "", err 865 - + } 866 - + defer resp.Body.Close() 867 - + 868 - + var body struct { 869 - + AccessToken string `json:"access_token"` 870 - + Error string `json:"error"` 871 - + } 872 - + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 873 - + return "", err 874 - + } 875 - + if body.AccessToken == "" { 876 - + return "", fmt.Errorf("no access token (status %d, error %q)", resp.StatusCode, body.Error) 877 - + } 878 - + return body.AccessToken, nil 879 - +} 880 - + 881 - +func githubGet(accessToken string, path string, out interface{}) error { 882 - + req, err := http.NewRequest(http.MethodGet, "https://api.github.com"+path, nil) 883 - + if err != nil { 884 - + return err 885 - + } 886 - + req.Header.Set("Authorization", "Bearer "+accessToken) 887 - + req.Header.Set("Accept", "application/vnd.github+json") 888 - + 889 - + resp, err := githubHttpClient.Do(req) 890 - + if err != nil { 891 - + return err 892 - + } 893 - + defer resp.Body.Close() 894 - + if resp.StatusCode != http.StatusOK { 895 - + return fmt.Errorf("GET %s: status %d", path, resp.StatusCode) 896 - + } 897 - + return json.NewDecoder(resp.Body).Decode(out) 898 - +}
-56
5pkgs/tincr/package.nix
··· 1 - { 2 - lib, 3 - rustPlatform, 4 - fetchFromGitHub, 5 - installShellFiles, 6 - pkg-config, 7 - openssl, 8 - }: 9 - 10 - rustPlatform.buildRustPackage { 11 - pname = "tincr"; 12 - version = "0-unstable-2026-09-14"; 13 - 14 - # Our fork's integration branch until Mic92/tincr#101 + #109 land: NAT-leaf dial 15 - # (#100), stale-loop-clock auth timeouts, tunnel-address gate, PMTU-decrease recovery. 16 - src = fetchFromGitHub { 17 - owner = "Lassulus"; 18 - repo = "tincr"; 19 - rev = "97b055f71ef80ea4b2d3e1e87b8819968ec00697"; 20 - hash = "sha256-sJ5sH0cP0+bEjFSspkP339jzwrSolZoYUVKopg8NDqc="; 21 - }; 22 - 23 - cargoHash = "sha256-RcyJ7NV3EH6GeIbapsQGJLBx8BkWbiFeeR4CB8k12ak="; 24 - 25 - # Just the deployable bin crates; --workspace would pull tinc-ffi's cc. 26 - cargoBuildFlags = [ 27 - "-p" 28 - "tincd" 29 - "-p" 30 - "tinc-tools" 31 - ]; 32 - 33 - # netns tests need bwrap+userns the build sandbox lacks. 34 - doCheck = false; 35 - 36 - # tinc-crypto's ChaPoly backend links openssl-sys against system openssl. 37 - nativeBuildInputs = [ 38 - installShellFiles 39 - pkg-config 40 - ]; 41 - buildInputs = [ openssl ]; 42 - 43 - postInstall = '' 44 - installManPage man/*.[0-9] 45 - ''; 46 - 47 - passthru.updateScript = ./update.sh; 48 - 49 - meta = { 50 - description = "Drop-in Rust rewrite of tinc 1.1 VPN (SPTPS-only, Ed25519)"; 51 - homepage = "https://github.com/Mic92/tincr"; 52 - license = lib.licenses.gpl2Plus; 53 - mainProgram = "tincd"; 54 - platforms = lib.platforms.linux ++ lib.platforms.darwin; 55 - }; 56 - }
-69
5pkgs/tincr/update.sh
··· 1 - #!/usr/bin/env bash 2 - set -euo pipefail 3 - 4 - # Tracks our fork's integration branch (Mic92/tincr#101 + #109); switch back to 5 - # Mic92/main once merged. 6 - owner=Lassulus 7 - repo=tincr 8 - branch=lassulus/fixes 9 - attr=tincr 10 - 11 - pkg="$(dirname "$(realpath "$0")")/package.nix" 12 - 13 - cd "$(dirname "$(realpath "$0")")" 14 - while [ ! -e flake.nix ] && [ "$PWD" != / ]; do cd ..; done 15 - system=$(nix eval --raw --impure --expr 'builtins.currentSystem') 16 - 17 - new_rev=$(curl -fsSL "https://api.github.com/repos/$owner/$repo/commits/$branch" | jq -r .sha) 18 - old_rev=$(grep -oP '^\s*rev = "\K[^"]+' "$pkg") 19 - if [ "$old_rev" = "$new_rev" ]; then 20 - echo "$attr: already up to date ($new_rev)" 21 - exit 0 22 - fi 23 - 24 - new_date=$(curl -fsSL "https://api.github.com/repos/$owner/$repo/commits/$new_rev" | jq -r .commit.author.date | cut -d T -f1) 25 - new_version="0-unstable-$new_date" 26 - 27 - # Bump rev + version, leave hashes for nix to complain about. 28 - # Blank both hashes too: with the old ones in place nix reuses the cached 29 - # FODs (old src + old vendor dir) and the build "succeeds" on stale code. 30 - sed -i \ 31 - -e "s|^\(\s*\)rev = \".*\"|\1rev = \"$new_rev\"|" \ 32 - -e "s|^\(\s*\)version = \".*\"|\1version = \"$new_version\"|" \ 33 - -e "s|^\(\s*\)hash = \".*\"|\1hash = \"\"|" \ 34 - -e "s|^\(\s*\)cargoHash = \".*\"|\1cargoHash = \"\"|" \ 35 - "$pkg" 36 - 37 - # Each FOD reports its own hash on first miss; build twice (src, then vendor). 38 - extract_got() { 39 - awk '/got:/{print $2; exit}' 40 - } 41 - update_hash() { 42 - local field=$1 new=$2 43 - sed -i "s|^\(\s*\)$field = \"[^\"]*\"|\1$field = \"$new\"|" "$pkg" 44 - } 45 - 46 - build_attr=".#packages.$system.$attr" 47 - 48 - # The probe build is *expected* to fail (hash mismatch); `|| true` keeps 49 - # pipefail/errexit from turning that into an empty `got`. awk must not 50 - # `exit` early or tee dies of SIGPIPE and takes the pipeline with it. 51 - probe_hash() { 52 - { nix build --no-link "$build_attr" 2>&1 || true; } | tee /dev/stderr \ 53 - | awk '/got:/ && !seen { print $2; seen = 1 }' || true 54 - } 55 - 56 - got=$(probe_hash) 57 - if [ -n "$got" ]; then 58 - update_hash hash "$got" 59 - echo "$attr: src hash → $got" 60 - fi 61 - 62 - got=$(probe_hash) 63 - if [ -n "$got" ]; then 64 - update_hash cargoHash "$got" 65 - echo "$attr: cargoHash → $got" 66 - fi 67 - 68 - echo "$attr: updated to $new_rev ($new_version)" 69 - nix build --no-link "$build_attr"
-67
5pkgs/vosk/package.nix
··· 1 - { 2 - lib, 3 - python3Packages, 4 - fetchurl, 5 - stdenv, 6 - autoPatchelfHook, 7 - }: 8 - let 9 - version = "0.3.44"; 10 - 11 - # Platform-specific wheels 12 - wheels = { 13 - "x86_64-linux" = { 14 - url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"; 15 - hash = "sha256-JlLvITxMeAfcQK8eeqV43Mz8TcQZKCKia8RalZG8v3Y="; 16 - }; 17 - "aarch64-linux" = { 18 - url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-manylinux2014_aarch64.whl"; 19 - hash = "sha256-mZW3+lQ0sTS0vQmJfiSQ28VhFcKtKUPAMGIeEMcDezQ="; 20 - }; 21 - "x86_64-darwin" = { 22 - url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-macosx_10_6_universal2.whl"; 23 - hash = "sha256-Ap0LPWpc/4dMV1uKWBSkzM+zdgjP9S6EbAKoxnqIKAE="; 24 - }; 25 - "aarch64-darwin" = { 26 - url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-macosx_10_6_universal2.whl"; 27 - hash = "sha256-Ap0LPWpc/4dMV1uKWBSkzM+zdgjP9S6EbAKoxnqIKAE="; 28 - }; 29 - }; 30 - 31 - wheel = 32 - wheels.${stdenv.hostPlatform.system} 33 - or (throw "Unsupported platform: ${stdenv.hostPlatform.system}"); 34 - in 35 - python3Packages.buildPythonPackage { 36 - pname = "vosk"; 37 - inherit version; 38 - format = "wheel"; 39 - 40 - src = fetchurl { 41 - inherit (wheel) url hash; 42 - }; 43 - 44 - nativeBuildInputs = [ autoPatchelfHook ]; 45 - 46 - buildInputs = [ stdenv.cc.cc.lib ]; 47 - 48 - dependencies = with python3Packages; [ 49 - cffi 50 - requests 51 - tqdm 52 - srt 53 - websockets 54 - ]; 55 - 56 - # Skip tests as they require audio files and models 57 - doCheck = false; 58 - 59 - pythonImportsCheck = [ "vosk" ]; 60 - 61 - meta = { 62 - description = "Offline speech recognition API based on Kaldi and Vosk"; 63 - homepage = "https://alphacephei.com/vosk/"; 64 - license = lib.licenses.asl20; 65 - platforms = lib.attrNames wheels; 66 - }; 67 - }
-35
5pkgs/zmk-studio/package.nix
··· 1 - { 2 - lib, 3 - appimageTools, 4 - fetchurl, 5 - }: 6 - 7 - let 8 - pname = "zmk-studio"; 9 - version = "0.3.1"; 10 - 11 - src = fetchurl { 12 - url = "https://github.com/zmkfirmware/zmk-studio/releases/download/v${version}/ZMK.Studio_${version}_amd64.AppImage"; 13 - hash = "sha256-kDt3AxPV1901WH3n6+mmylLwnvRrAuHSlFlRK9qEoOs="; 14 - }; 15 - 16 - appimageContents = appimageTools.extractType2 { inherit pname version src; }; 17 - in 18 - appimageTools.wrapType2 { 19 - inherit pname version src; 20 - 21 - extraInstallCommands = '' 22 - install -Dm444 "${appimageContents}/ZMK Studio.desktop" $out/share/applications/zmk-studio.desktop 23 - install -Dm444 ${appimageContents}/zmk-studio.png $out/share/icons/hicolor/128x128/apps/zmk-studio.png 24 - ''; 25 - 26 - meta = { 27 - description = "Runtime keymap editor for ZMK keyboards"; 28 - homepage = "https://zmk.studio/"; 29 - downloadPage = "https://github.com/zmkfirmware/zmk-studio/releases"; 30 - license = lib.licenses.mit; 31 - maintainers = [ ]; 32 - platforms = [ "x86_64-linux" ]; 33 - mainProgram = "zmk-studio"; 34 - }; 35 - }
+1 -1
machines/barnacle/retiolum.nix
··· 7 7 }: 8 8 9 9 # Retiolum node on nix-darwin. tincr's own darwin module does the launchd 10 - # plumbing; the daemon is the same 5pkgs/tincr the NixOS machines run (they 10 + # plumbing; the daemon is the same pkgs/tincr the NixOS machines run (they 11 11 # use nixpkgs' services.tinc, see 2configs/retiolum.nix). kartei is only the 12 12 # host database here, same as on NixOS — its darwin retiolum shim would 13 13 # pull tincd from kartei's own tincr pin instead.
+38
pkgs/age-plugin-gpg/package.nix
··· 1 + { 2 + lib, 3 + rustPlatform, 4 + fetchFromGitHub, 5 + pkg-config, 6 + nettle, 7 + }: 8 + rustPlatform.buildRustPackage (finalAttrs: { 9 + pname = "age-plugin-gpg"; 10 + version = "0.1.0"; 11 + 12 + src = fetchFromGitHub { 13 + owner = "CertainLach"; 14 + repo = "age-plugin-gpg"; 15 + rev = "v${finalAttrs.version}"; 16 + hash = "sha256-HfCl+YAjKPNFtLAHjNyjlHhRxD85qZLWsfKzqORtj5U="; 17 + }; 18 + 19 + cargoLock = { 20 + lockFile = "${finalAttrs.src}/Cargo.lock"; 21 + }; 22 + 23 + nativeBuildInputs = [ 24 + pkg-config 25 + rustPlatform.bindgenHook 26 + ]; 27 + 28 + buildInputs = [ nettle ]; 29 + 30 + meta = { 31 + description = "Age plugin utilizing gpg-agent & keygrips as recipients/identities"; 32 + homepage = "https://github.com/CertainLach/age-plugin-gpg"; 33 + license = lib.licenses.mit; 34 + mainProgram = "age-plugin-gpg"; 35 + platforms = lib.platforms.unix; 36 + maintainers = [ ]; 37 + }; 38 + })
+24
pkgs/archiver-bot/package.nix
··· 1 + { 2 + python3Packages, 3 + lib, 4 + }: 5 + python3Packages.buildPythonApplication { 6 + pname = "archiver-bot"; 7 + version = "0.1.0"; 8 + pyproject = true; 9 + 10 + src = ./.; 11 + 12 + build-system = [ python3Packages.setuptools ]; 13 + 14 + dependencies = with python3Packages; [ 15 + (matrix-nio.override { withVodozemac = true; }) 16 + aiohttp 17 + ]; 18 + 19 + meta = { 20 + description = "Matrix bot that requests movies/shows via Jellyseerr when IMDb links are posted"; 21 + mainProgram = "archiver-bot"; 22 + platforms = lib.platforms.all; 23 + }; 24 + }
+14
pkgs/archiver-bot/pyproject.toml
··· 1 + [build-system] 2 + requires = ["setuptools"] 3 + build-backend = "setuptools.build_meta" 4 + 5 + [project] 6 + name = "archiver-bot" 7 + version = "0.1.0" 8 + dependencies = [ 9 + "matrix-nio", 10 + "aiohttp", 11 + ] 12 + 13 + [project.scripts] 14 + archiver-bot = "archiver_bot:main"
+19
pkgs/bank/package.nix
··· 1 + { 2 + writers, 3 + coreutils, 4 + pass, 5 + hledger, 6 + diffutils, 7 + }: 8 + 9 + writers.writeDashBin "bank" '' 10 + tmp=$(mktemp) 11 + ${pass}/bin/pass show hledger > $tmp 12 + ${hledger}/bin/hledger --file=$tmp "$@" 13 + ${pass}/bin/pass show hledger | if ${diffutils}/bin/diff $tmp -; then 14 + exit 0 15 + else 16 + ${coreutils}/bin/cat $tmp | ${pass}/bin/pass insert -m hledger 17 + fi 18 + ${coreutils}/bin/rm $tmp 19 + ''
+90
pkgs/dectalk/package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + fetchFromGitHub, 5 + autoconf, 6 + automake, 7 + pkg-config, 8 + libpulseaudio, 9 + alsa-lib, 10 + }: 11 + 12 + stdenv.mkDerivation { 13 + pname = "dectalk"; 14 + version = "0-unstable-2025-10-27"; 15 + 16 + src = fetchFromGitHub { 17 + owner = "dectalk"; 18 + repo = "dectalk"; 19 + rev = "c3c1e4ae4117f951d8d7f9ef2848408ec79047fd"; 20 + hash = "sha256-28d8/LLxtkfjBLRz8jH6xGf16koxMZqm2h67bAey4qg="; 21 + }; 22 + 23 + nativeBuildInputs = [ 24 + autoconf 25 + automake 26 + pkg-config 27 + ]; 28 + 29 + buildInputs = [ 30 + libpulseaudio 31 + alsa-lib 32 + ]; 33 + 34 + postPatch = '' 35 + # Replace git version detection with static version since .git is not available 36 + sed -i 's/m4_esyscmd_s(echo $(git describe --always)$(git status --porcelain | awk .*))/c3c1e4a/' src/configure.ac 37 + ''; 38 + 39 + configurePhase = '' 40 + runHook preConfigure 41 + cd src 42 + ./autogen.sh 43 + ./configure --prefix=$out 44 + cd .. 45 + runHook postConfigure 46 + ''; 47 + 48 + buildPhase = '' 49 + runHook preBuild 50 + make -C src -j$NIX_BUILD_CORES 51 + runHook postBuild 52 + ''; 53 + 54 + installPhase = '' 55 + runHook preInstall 56 + mkdir -p $out/bin $out/lib $out/dic 57 + 58 + # Libraries 59 + cp dist/lib/*.so $out/lib/ 60 + 61 + # Main binaries 62 + for bin in say dtmemory aclock; do 63 + if [ -f "dist/$bin" ]; then 64 + cp "dist/$bin" $out/bin/ 65 + fi 66 + done 67 + 68 + # Tools 69 + if [ -d dist/tools ]; then 70 + cp dist/tools/* $out/bin/ 71 + fi 72 + 73 + # Dictionaries 74 + cp dist/dic/* $out/dic/ 75 + 76 + # DECtalk config — must be at $out/DECtalk.conf (compiled-in DECTALK_INSTALL_PREFIX) 77 + cp dist/DECtalk.conf $out/DECtalk.conf 78 + 79 + runHook postInstall 80 + ''; 81 + 82 + meta = { 83 + description = "Modern builds of the DECtalk text-to-speech engine"; 84 + homepage = "https://github.com/dectalk/dectalk"; 85 + # license = lib.licenses.unfree; 86 + license = lib.licenses.free; 87 + platforms = lib.platforms.linux; 88 + mainProgram = "say"; 89 + }; 90 + }
+170
pkgs/elinks/package.nix
··· 1 + # Replacement for nixpkgs's `elinks`, switched to the meson build from 2 + # upstream nixpkgs PR #515347 (https://github.com/NixOS/nixpkgs/pull/515347). 3 + # 4 + # nixpkgs's autotools build of elinks 0.19.1 is broken against gettext 5 + # 0.23.1 (autopoint refuses `AM_GNU_GETTEXT` without an `external` 6 + # argument; further, autoheader then fails on `HAVE_ALLOCA`). The PR 7 + # sidesteps autotools entirely. We carry it locally until it lands. 8 + # 9 + # The PR also moves a few entries from `buildInputs` to 10 + # `propagatedBuildInputs` on libcss and libdom so they're picked up 11 + # transitively. Rather than overlaying those packages globally, we 12 + # tweak them inline here (only this elinks build sees the change). 13 + { 14 + lib, 15 + stdenv, 16 + fetchFromGitHub, 17 + ncurses, 18 + bzip2, 19 + zlib, 20 + brotli, 21 + zstd, 22 + xz, 23 + openssl, 24 + meson, 25 + ninja, 26 + gettext, 27 + python3, 28 + perl, 29 + pkg-config, 30 + xmlto, 31 + docbook_xml_dtd_42, 32 + gpm, 33 + libidn2, 34 + tre, 35 + expat, 36 + lua, 37 + curl, 38 + libcss, 39 + libdom, 40 + libparserutils, 41 + libwapcaplet, 42 + libhubbub, 43 + enableGuile ? false, 44 + guile ? null, 45 + enablePython ? false, 46 + python ? null, 47 + enablePerl ? (!stdenv.hostPlatform.isDarwin) && (stdenv.hostPlatform == stdenv.buildPlatform), 48 + }: 49 + 50 + let 51 + # libcss with libparserutils/libwapcaplet propagated, so meson's 52 + # pkg-config lookup finds them when elinks links against libcss. 53 + libcss' = libcss.overrideAttrs (old: { 54 + buildInputs = lib.filter (d: d != libparserutils && d != libwapcaplet) (old.buildInputs or [ ]); 55 + propagatedBuildInputs = (old.propagatedBuildInputs or [ ]) ++ [ 56 + libparserutils 57 + libwapcaplet 58 + ]; 59 + }); 60 + 61 + # libdom: libhubbub/libwapcaplet propagated, for the same reason. 62 + libdom' = libdom.overrideAttrs (old: { 63 + buildInputs = lib.filter (d: d != libhubbub && d != libwapcaplet) (old.buildInputs or [ ]); 64 + propagatedBuildInputs = (old.propagatedBuildInputs or [ ]) ++ [ 65 + libhubbub 66 + libwapcaplet 67 + ]; 68 + }); 69 + in 70 + stdenv.mkDerivation (finalAttrs: { 71 + pname = "elinks"; 72 + version = "0.19.1"; 73 + 74 + src = fetchFromGitHub { 75 + owner = "rkd77"; 76 + repo = "elinks"; 77 + rev = "v${finalAttrs.version}"; 78 + hash = "sha256-aQ+q2I6uTVv5kpKBaGJ1xiE/9vv9T7JI05VX/ROkAqA="; 79 + }; 80 + 81 + outputs = [ 82 + "out" 83 + "man" 84 + "doc" 85 + ]; 86 + 87 + buildInputs = [ 88 + ncurses 89 + bzip2 90 + zlib 91 + brotli 92 + zstd 93 + xz 94 + openssl 95 + libidn2 96 + tre 97 + expat 98 + lua 99 + curl 100 + libcss' 101 + libdom' 102 + ] 103 + ++ lib.optional stdenv.hostPlatform.isDarwin gettext 104 + ++ lib.optional enableGuile guile 105 + ++ lib.optional enablePython python 106 + ++ lib.optional enablePerl perl; 107 + 108 + nativeBuildInputs = [ 109 + meson 110 + ninja 111 + gettext 112 + perl 113 + python3 114 + pkg-config 115 + xmlto 116 + ]; 117 + 118 + env = 119 + lib.optionalAttrs stdenv.hostPlatform.isLinux { 120 + C_INCLUDE_PATH = "${lib.getInclude gpm}/include"; 121 + LIBRARY_PATH = "${lib.getLib gpm}/lib"; 122 + } 123 + // lib.optionalAttrs stdenv.hostPlatform.isDarwin { 124 + LDFLAGS = "-liconv"; 125 + }; 126 + 127 + strictDeps = true; 128 + __structuredAttrs = true; 129 + 130 + mesonFlags = 131 + (map (f: lib.mesonBool f true) [ 132 + "finger" 133 + "html-highlight" 134 + "gopher" 135 + "gemini" 136 + "cgi" 137 + "nntp" 138 + "256-colors" 139 + "true-color" 140 + "brotli" 141 + "lzma" 142 + "terminfo" 143 + "reproducible" 144 + ]) 145 + ++ [ 146 + (lib.mesonOption "luapkg" "lua") 147 + (lib.mesonBool "gpm" stdenv.hostPlatform.isLinux) 148 + (lib.mesonBool "guile" enableGuile) 149 + (lib.mesonBool "python" enablePython) 150 + (lib.mesonBool "perl" enablePerl) 151 + ]; 152 + 153 + postPatch = '' 154 + patchShebangs doc/tools 155 + substituteInPlace doc/tools/asciidoc/docbook.conf \ 156 + --replace-fail "http://www.oasis-open.org/docbook/xml/4.2/docbookx.dtd" "${docbook_xml_dtd_42}/xml/dtd/docbook/docbookx.dtd" 157 + ''; 158 + 159 + preConfigure = '' 160 + mesonFlags+=("-Dsource-date-epoch=$SOURCE_DATE_EPOCH") 161 + ''; 162 + 163 + meta = { 164 + description = "Full-featured text-mode web browser (meson build, PR #515347)"; 165 + mainProgram = "elinks"; 166 + homepage = "https://github.com/rkd77/elinks"; 167 + license = lib.licenses.gpl2Only; 168 + platforms = with lib.platforms; linux ++ darwin; 169 + }; 170 + })
+356
pkgs/git-mcp/git-mcp.py
··· 1 + #!/usr/bin/env python3 2 + """ 3 + Git MCP Server - Advanced Git operations for AI assistants 4 + """ 5 + 6 + import asyncio 7 + from typing import Dict, List, Any 8 + import os 9 + 10 + from mcp.server import Server 11 + from mcp.server.stdio import stdio_server 12 + from mcp.types import Tool, TextContent 13 + 14 + 15 + class GitMCP: 16 + def __init__(self): 17 + self.repo_path = os.getcwd() 18 + 19 + async def run_git_command(self, cmd: List[str]) -> Dict[str, Any]: 20 + """Run a git command and return the result""" 21 + try: 22 + result = await asyncio.create_subprocess_exec( 23 + "git", 24 + *cmd, 25 + stdout=asyncio.subprocess.PIPE, 26 + stderr=asyncio.subprocess.PIPE, 27 + cwd=self.repo_path, 28 + ) 29 + stdout, stderr = await result.communicate() 30 + 31 + if result.returncode == 0: 32 + return {"output": stdout.decode("utf-8")} 33 + else: 34 + return {"error": stderr.decode("utf-8")} 35 + except Exception as e: 36 + return {"error": str(e)} 37 + 38 + async def git_status(self, args: Dict[str, Any]) -> str: 39 + """Get detailed git status""" 40 + cmd = ["status", "--porcelain"] 41 + if args.get("untracked", True): 42 + cmd.append("--untracked-files=all") 43 + if args.get("ignored"): 44 + cmd.append("--ignored") 45 + 46 + result = await self.run_git_command(cmd) 47 + if "error" in result: 48 + return f"Error: {result['error']}" 49 + 50 + return result["output"] 51 + 52 + async def git_diff(self, args: Dict[str, Any]) -> str: 53 + """Get diff with various options""" 54 + cmd = ["diff"] 55 + if args.get("staged"): 56 + cmd.append("--staged") 57 + if args.get("commit"): 58 + cmd.append(args["commit"]) 59 + if args.get("context"): 60 + cmd.extend(["-U", str(args["context"])]) 61 + if args.get("files"): 62 + cmd.extend(["--"] + args["files"]) 63 + 64 + result = await self.run_git_command(cmd) 65 + if "error" in result: 66 + return f"Error: {result['error']}" 67 + 68 + return result["output"] 69 + 70 + async def git_log(self, args: Dict[str, Any]) -> str: 71 + """Get commit history""" 72 + cmd = ["log", "--oneline"] 73 + if args.get("limit"): 74 + cmd.extend(["-n", str(args["limit"])]) 75 + if args.get("author"): 76 + cmd.extend(["--author", args["author"]]) 77 + if args.get("since"): 78 + cmd.extend(["--since", args["since"]]) 79 + if args.get("grep"): 80 + cmd.extend(["--grep", args["grep"]]) 81 + if args.get("files"): 82 + cmd.extend(["--"] + args["files"]) 83 + 84 + result = await self.run_git_command(cmd) 85 + if "error" in result: 86 + return f"Error: {result['error']}" 87 + 88 + return result["output"] 89 + 90 + async def git_branch(self, args: Dict[str, Any]) -> str: 91 + """Manage branches""" 92 + action = args.get("action", "list") 93 + 94 + if action == "list": 95 + cmd = ["branch", "-v"] 96 + if args.get("remote"): 97 + cmd.append("-r") 98 + elif action == "create": 99 + if not args.get("name"): 100 + return "Error: Branch name required" 101 + cmd = ["checkout", "-b", args["name"]] 102 + elif action == "delete": 103 + if not args.get("name"): 104 + return "Error: Branch name required" 105 + cmd = ["branch", "-d", args["name"]] 106 + else: 107 + return f"Error: Unknown action: {action}" 108 + 109 + result = await self.run_git_command(cmd) 110 + if "error" in result: 111 + return f"Error: {result['error']}" 112 + 113 + return result["output"] 114 + 115 + async def git_add(self, args: Dict[str, Any]) -> str: 116 + """Stage files""" 117 + files = args.get("files", []) 118 + if not files: 119 + return "Error: No files specified" 120 + 121 + cmd = ["add"] + files 122 + result = await self.run_git_command(cmd) 123 + if "error" in result: 124 + return f"Error: {result['error']}" 125 + 126 + return "Files staged successfully" 127 + 128 + async def git_commit(self, args: Dict[str, Any]) -> str: 129 + """Create a commit""" 130 + message = args.get("message") 131 + if not message: 132 + return "Error: Commit message required" 133 + 134 + cmd = ["commit", "-m", message] 135 + result = await self.run_git_command(cmd) 136 + if "error" in result: 137 + return f"Error: {result['error']}" 138 + 139 + return result["output"] 140 + 141 + async def git_push(self, args: Dict[str, Any]) -> str: 142 + """Push commits""" 143 + cmd = ["push"] 144 + if args.get("remote"): 145 + cmd.append(args["remote"]) 146 + if args.get("branch"): 147 + cmd.append(args["branch"]) 148 + 149 + result = await self.run_git_command(cmd) 150 + if "error" in result: 151 + return f"Error: {result['error']}" 152 + 153 + return result["output"] 154 + 155 + async def git_pull(self, args: Dict[str, Any]) -> str: 156 + """Pull commits""" 157 + cmd = ["pull"] 158 + if args.get("remote"): 159 + cmd.append(args["remote"]) 160 + if args.get("branch"): 161 + cmd.append(args["branch"]) 162 + 163 + result = await self.run_git_command(cmd) 164 + if "error" in result: 165 + return f"Error: {result['error']}" 166 + 167 + return result["output"] 168 + 169 + 170 + async def main(): 171 + git_mcp = GitMCP() 172 + 173 + # Create the MCP server 174 + server = Server("git-mcp") 175 + 176 + # Register tools 177 + @server.list_tools() 178 + async def list_tools() -> List[Tool]: 179 + return [ 180 + Tool( 181 + name="git_status", 182 + description="Get git repository status", 183 + inputSchema={ 184 + "type": "object", 185 + "properties": { 186 + "untracked": { 187 + "type": "boolean", 188 + "description": "Include untracked files", 189 + }, 190 + "ignored": { 191 + "type": "boolean", 192 + "description": "Include ignored files", 193 + }, 194 + }, 195 + "additionalProperties": False, 196 + }, 197 + ), 198 + Tool( 199 + name="git_diff", 200 + description="Get git diff", 201 + inputSchema={ 202 + "type": "object", 203 + "properties": { 204 + "staged": { 205 + "type": "boolean", 206 + "description": "Show staged changes", 207 + }, 208 + "commit": { 209 + "type": "string", 210 + "description": "Show diff for specific commit", 211 + }, 212 + "files": { 213 + "type": "array", 214 + "items": {"type": "string"}, 215 + "description": "Specific files to diff", 216 + }, 217 + "context": { 218 + "type": "number", 219 + "description": "Lines of context", 220 + }, 221 + }, 222 + "additionalProperties": False, 223 + }, 224 + ), 225 + Tool( 226 + name="git_log", 227 + description="Get commit history", 228 + inputSchema={ 229 + "type": "object", 230 + "properties": { 231 + "limit": {"type": "number", "description": "Number of commits"}, 232 + "author": {"type": "string", "description": "Filter by author"}, 233 + "since": {"type": "string", "description": "Since date"}, 234 + "grep": { 235 + "type": "string", 236 + "description": "Search commit messages", 237 + }, 238 + "files": { 239 + "type": "array", 240 + "items": {"type": "string"}, 241 + "description": "Show commits affecting files", 242 + }, 243 + }, 244 + "additionalProperties": False, 245 + }, 246 + ), 247 + Tool( 248 + name="git_branch", 249 + description="Manage branches", 250 + inputSchema={ 251 + "type": "object", 252 + "properties": { 253 + "action": { 254 + "type": "string", 255 + "enum": ["list", "create", "delete"], 256 + "description": "Action to perform", 257 + }, 258 + "name": {"type": "string", "description": "Branch name"}, 259 + "remote": { 260 + "type": "boolean", 261 + "description": "Include remote branches", 262 + }, 263 + }, 264 + "additionalProperties": False, 265 + }, 266 + ), 267 + Tool( 268 + name="git_add", 269 + description="Stage files for commit", 270 + inputSchema={ 271 + "type": "object", 272 + "properties": { 273 + "files": { 274 + "type": "array", 275 + "items": {"type": "string"}, 276 + "description": "Files to stage", 277 + } 278 + }, 279 + "required": ["files"], 280 + "additionalProperties": False, 281 + }, 282 + ), 283 + Tool( 284 + name="git_commit", 285 + description="Create a commit", 286 + inputSchema={ 287 + "type": "object", 288 + "properties": { 289 + "message": {"type": "string", "description": "Commit message"} 290 + }, 291 + "required": ["message"], 292 + "additionalProperties": False, 293 + }, 294 + ), 295 + Tool( 296 + name="git_push", 297 + description="Push commits to remote", 298 + inputSchema={ 299 + "type": "object", 300 + "properties": { 301 + "remote": {"type": "string", "description": "Remote name"}, 302 + "branch": {"type": "string", "description": "Branch name"}, 303 + }, 304 + "additionalProperties": False, 305 + }, 306 + ), 307 + Tool( 308 + name="git_pull", 309 + description="Pull commits from remote", 310 + inputSchema={ 311 + "type": "object", 312 + "properties": { 313 + "remote": {"type": "string", "description": "Remote name"}, 314 + "branch": {"type": "string", "description": "Branch name"}, 315 + }, 316 + "additionalProperties": False, 317 + }, 318 + ), 319 + ] 320 + 321 + @server.call_tool() 322 + async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]: 323 + """Handle tool calls""" 324 + try: 325 + if name == "git_status": 326 + result = await git_mcp.git_status(arguments) 327 + elif name == "git_diff": 328 + result = await git_mcp.git_diff(arguments) 329 + elif name == "git_log": 330 + result = await git_mcp.git_log(arguments) 331 + elif name == "git_branch": 332 + result = await git_mcp.git_branch(arguments) 333 + elif name == "git_add": 334 + result = await git_mcp.git_add(arguments) 335 + elif name == "git_commit": 336 + result = await git_mcp.git_commit(arguments) 337 + elif name == "git_push": 338 + result = await git_mcp.git_push(arguments) 339 + elif name == "git_pull": 340 + result = await git_mcp.git_pull(arguments) 341 + else: 342 + result = f"Unknown tool: {name}" 343 + 344 + return [TextContent(type="text", text=result)] 345 + except Exception as e: 346 + return [TextContent(type="text", text=f"Error: {str(e)}")] 347 + 348 + # Run the server 349 + async with stdio_server() as (read_stream, write_stream): 350 + await server.run( 351 + read_stream, write_stream, server.create_initialization_options() 352 + ) 353 + 354 + 355 + if __name__ == "__main__": 356 + asyncio.run(main())
+18
pkgs/git-mcp/package.nix
··· 1 + { pkgs, ... }: 2 + let 3 + python = pkgs.python3.withPackages ( 4 + ps: with ps; [ 5 + mcp 6 + ] 7 + ); 8 + in 9 + pkgs.writeShellApplication { 10 + name = "git-mcp"; 11 + runtimeInputs = with pkgs; [ 12 + git 13 + python 14 + ]; 15 + text = '' 16 + ${python}/bin/python ${./git-mcp.py} "$@" 17 + ''; 18 + }
+81
pkgs/gomuks/package.nix
··· 1 + { 2 + lib, 3 + fetchFromGitHub, 4 + fetchNpmDeps, 5 + buildGoModule, 6 + nodejs, 7 + npmHooks, 8 + }: 9 + 10 + # gomuks TUI with E2EE via pure-Go goolm (no insecure libolm needed) 11 + # Built from the new gomuks codebase which includes cmd/gomuks-terminal 12 + buildGoModule ( 13 + finalAttrs: 14 + let 15 + rev = "82abf26775d59c642fa7ea5274cf8631cd6942c6"; 16 + in 17 + { 18 + pname = "gomuks"; 19 + version = "0.2601.0"; 20 + 21 + proxyVendor = true; 22 + vendorHash = "sha256-M493helHfjHyJCqHxyaC2wkhtsFNA937crdnfCWj9IQ="; 23 + 24 + src = fetchFromGitHub { 25 + owner = "gomuks"; 26 + repo = "gomuks"; 27 + hash = "sha256-0AqA1Fl6xIotmF1hYfEm2FEEiSBzBp7RDnaJjTBeTwU="; 28 + inherit rev; 29 + }; 30 + 31 + nativeBuildInputs = [ 32 + nodejs 33 + npmHooks.npmConfigHook 34 + ]; 35 + 36 + env = { 37 + npmRoot = "web"; 38 + npmDeps = fetchNpmDeps { 39 + src = "${finalAttrs.src}/web"; 40 + hash = "sha256-SUxE4SZOZI+GgLTecRf5rqYRv3koV85eCZ1TFKO4gTI="; 41 + }; 42 + }; 43 + 44 + postPatch = '' 45 + substituteInPlace ./web/build-wasm.sh \ 46 + --replace-fail 'go.mau.fi/gomuks/version.Tag=$(git describe --exact-match --tags 2>/dev/null)' "go.mau.fi/gomuks/version.Tag=v${finalAttrs.version}" \ 47 + --replace-fail 'go.mau.fi/gomuks/version.Commit=$(git rev-parse HEAD)' "go.mau.fi/gomuks/version.Commit=${rev}" 48 + ''; 49 + 50 + doCheck = false; 51 + 52 + tags = [ "goolm" ]; 53 + 54 + ldflags = [ 55 + "-X 'go.mau.fi/gomuks/version.Tag=v${finalAttrs.version}'" 56 + "-X 'go.mau.fi/gomuks/version.Commit=${rev}'" 57 + ]; 58 + 59 + subPackages = [ 60 + "cmd/gomuks" 61 + "cmd/gomuks-terminal" 62 + ]; 63 + 64 + preBuild = '' 65 + CGO_ENABLED=0 go generate ./web 66 + ''; 67 + 68 + postInstall = '' 69 + mv $out/bin/gomuks $out/bin/gomuks-server 70 + mv $out/bin/gomuks-terminal $out/bin/gomuks-tui 71 + ''; 72 + 73 + meta = { 74 + mainProgram = "gomuks"; 75 + description = "Terminal Matrix client with E2EE via goolm"; 76 + homepage = "https://github.com/tulir/gomuks"; 77 + license = lib.licenses.agpl3Only; 78 + platforms = lib.platforms.unix; 79 + }; 80 + } 81 + )
+54
pkgs/kokoro/package.nix
··· 1 + { 2 + python3, 3 + fetchurl, 4 + fetchFromGitHub, 5 + }: 6 + let 7 + # Create a python environment with our overrides 8 + python = python3.override { 9 + self = python; 10 + packageOverrides = final: prev: { 11 + # Override kokoro with a more recent version and stdout support 12 + kokoro = prev.kokoro.overridePythonAttrs (old: { 13 + version = "0-unstable-2025-08-06"; 14 + src = fetchFromGitHub { 15 + owner = "hexgrad"; 16 + repo = "kokoro"; 17 + rev = "dfb907a02bba8152ca444717ca5d78747ccb4bec"; 18 + hash = "sha256-GJlc3+RCeYaAvojFFjK22nitDTWFWp6dAPJakw+//j8="; 19 + }; 20 + patches = (old.patches or [ ]) ++ [ 21 + ./stdout-support.patch 22 + ]; 23 + }); 24 + # Spacy model - downloaded and packaged as a python package 25 + spacy-en-core-web-sm = final.buildPythonPackage rec { 26 + pname = "en_core_web_sm"; 27 + version = "3.8.0"; 28 + format = "wheel"; 29 + 30 + src = fetchurl { 31 + url = "https://github.com/explosion/spacy-models/releases/download/${pname}-${version}/${pname}-${version}-py3-none-any.whl"; 32 + hash = "sha256-GTJCnbcn1L/z3u1rNM/AXfF3lPSlLusmz4ko98Gg+4U="; 33 + }; 34 + 35 + propagatedBuildInputs = [ final.spacy ]; 36 + 37 + pythonImportsCheck = [ "en_core_web_sm" ]; 38 + }; 39 + 40 + # Create a patched misaki that doesn't try to download spacy models 41 + misaki = prev.misaki.overridePythonAttrs (old: { 42 + postPatch = (old.postPatch or "") + '' 43 + # Patch out the spacy model download - we provide it as a dependency instead 44 + substituteInPlace misaki/en.py \ 45 + --replace-fail "if not spacy.util.is_package(name):" "if False:" \ 46 + --replace-fail "spacy.cli.download(name)" "pass" 47 + ''; 48 + 49 + propagatedBuildInputs = (old.propagatedBuildInputs or [ ]) ++ [ final.spacy-en-core-web-sm ]; 50 + }); 51 + }; 52 + }; 53 + in 54 + python.pkgs.kokoro
+153
pkgs/kokoro/stdout-support.patch
··· 1 + --- a/kokoro/__main__.py 2025-12-21 20:28:56.595838972 +0100 2 + +++ b/kokoro/__main__.py 2025-12-21 20:50:06.842504063 +0100 3 + @@ -3,7 +3,10 @@ 4 + python3 -m kokoro --text "The sky above the port was the color of television, tuned to a dead channel." -o file.wav --debug 5 + 6 + echo "Bom dia mundo, como vão vocês" > text.txt 7 + -python3 -m kokoro -i text.txt -l p --voice pm_alex > audio.wav 8 + +python3 -m kokoro -i text.txt -l p --voice pm_alex -o output.wav 9 + + 10 + +# Streaming from stdin to stdout (pipe to mpv): 11 + +ollama run llama3 "tell me a joke" | python3 -m kokoro -o - --stream | mpv --demuxer=rawaudio --demuxer-rawaudio-rate=24000 --demuxer-rawaudio-format=s16le --demuxer-rawaudio-channels=1 - 12 + 13 + Common issues: 14 + pip not installed: `uv pip install pip` 15 + @@ -13,6 +16,7 @@ 16 + """ 17 + 18 + import argparse 19 + +import sys 20 + import wave 21 + from pathlib import Path 22 + from typing import Generator, TYPE_CHECKING 23 + @@ -47,22 +51,58 @@ 24 + yield from pipeline(text, voice=voice, speed=speed, split_pattern=r"\n+") 25 + 26 + 27 + +def write_wav_audio(wav_file, text: str, kokoro_language: str, voice: str, speed=1) -> None: 28 + + """Write audio frames to an open wav file handle.""" 29 + + wav_file.setnchannels(1) # Mono audio 30 + + wav_file.setsampwidth(2) # 2 bytes per sample (16-bit audio) 31 + + wav_file.setframerate(24000) # Sample rate 32 + + 33 + + for result in generate_audio( 34 + + text, kokoro_language=kokoro_language, voice=voice, speed=speed 35 + + ): 36 + + logger.debug(result.phonemes) 37 + + if result.audio is None: 38 + + continue 39 + + audio_bytes = (result.audio.numpy() * 32767).astype(np.int16).tobytes() 40 + + wav_file.writeframes(audio_bytes) 41 + + 42 + + 43 + def generate_and_save_audio( 44 + - output_file: Path, text: str, kokoro_language: str, voice: str, speed=1 45 + + output_file: str, text: str, kokoro_language: str, voice: str, speed=1 46 + +) -> None: 47 + + if output_file == "-": 48 + + # Write to stdout 49 + + with wave.open(sys.stdout.buffer, "wb") as wav_file: 50 + + write_wav_audio(wav_file, text, kokoro_language, voice, speed) 51 + + else: 52 + + with wave.open(str(Path(output_file).resolve()), "wb") as wav_file: 53 + + write_wav_audio(wav_file, text, kokoro_language, voice, speed) 54 + + 55 + + 56 + +def stream_audio_raw( 57 + + input_stream, kokoro_language: str, voice: str, speed: float 58 + ) -> None: 59 + - with wave.open(str(output_file.resolve()), "wb") as wav_file: 60 + - wav_file.setnchannels(1) # Mono audio 61 + - wav_file.setsampwidth(2) # 2 bytes per sample (16-bit audio) 62 + - wav_file.setframerate(24000) # Sample rate 63 + - 64 + - for result in generate_audio( 65 + - text, kokoro_language=kokoro_language, voice=voice, speed=speed 66 + - ): 67 + + """Stream audio as raw PCM s16le to stdout, processing stdin line by line.""" 68 + + from kokoro import KPipeline 69 + + 70 + + if not voice.startswith(kokoro_language): 71 + + logger.warning(f"Voice {voice} is not made for language {kokoro_language}") 72 + + 73 + + pipeline = KPipeline(lang_code=kokoro_language) 74 + + output = sys.stdout.buffer 75 + + 76 + + for line in input_stream: 77 + + line = line.rstrip('\n\r') 78 + + if not line: 79 + + continue 80 + + logger.debug(f"Processing line: {line!r}") 81 + + for result in pipeline(line, voice=voice, speed=speed, split_pattern=r"\n+"): 82 + logger.debug(result.phonemes) 83 + if result.audio is None: 84 + continue 85 + audio_bytes = (result.audio.numpy() * 32767).astype(np.int16).tobytes() 86 + - wav_file.writeframes(audio_bytes) 87 + + output.write(audio_bytes) 88 + + output.flush() 89 + 90 + 91 + def main() -> None: 92 + @@ -83,8 +123,8 @@ 93 + "-o", 94 + "--output-file", 95 + "--output_file", 96 + - type=Path, 97 + - help="Path to output WAV file", 98 + + type=str, 99 + + help="Path to output WAV file (use - for stdout)", 100 + required=True, 101 + ) 102 + parser.add_argument( 103 + @@ -107,6 +147,11 @@ 104 + help="Speech speed", 105 + ) 106 + parser.add_argument( 107 + + "--stream", 108 + + action="store_true", 109 + + help="Stream mode: read stdin line-by-line, output raw PCM s16le 24kHz mono (for piping to mpv)", 110 + + ) 111 + + parser.add_argument( 112 + "--debug", 113 + action="store_true", 114 + help="Print DEBUG messages to console", 115 + @@ -118,6 +163,20 @@ 116 + 117 + lang = args.language or args.voice[0] 118 + 119 + + # Streaming mode: read stdin line by line, output raw PCM 120 + + if args.stream: 121 + + if args.output_file != "-": 122 + + logger.warning("Streaming mode always outputs to stdout, ignoring output file") 123 + + if args.text: 124 + + raise Exception("Cannot use --text with --stream, use stdin") 125 + + if args.input_file: 126 + + with open(args.input_file) as f: 127 + + stream_audio_raw(f, lang, args.voice, args.speed) 128 + + else: 129 + + stream_audio_raw(sys.stdin, lang, args.voice, args.speed) 130 + + return 131 + + 132 + + # Non-streaming mode: read all input, output WAV 133 + if args.text is not None and args.input_file is not None: 134 + raise Exception("You cannot specify both 'text' and 'input_file'") 135 + elif args.text: 136 + @@ -126,14 +185,13 @@ 137 + file: Path = args.input_file 138 + text = file.read_text() 139 + else: 140 + - import sys 141 + - print("Press Ctrl+D to stop reading input and start generating", flush=True) 142 + + print("Press Ctrl+D to stop reading input and start generating", file=sys.stderr, flush=True) 143 + text = '\n'.join(sys.stdin) 144 + 145 + logger.debug(f"Input text: {text!r}") 146 + 147 + - out_file: Path = args.output_file 148 + - if not out_file.suffix == ".wav": 149 + + out_file: str = args.output_file 150 + + if out_file != "-" and not out_file.endswith(".wav"): 151 + logger.warning("The output file name should end with .wav") 152 + generate_and_save_audio( 153 + output_file=out_file,
+157
pkgs/levenshtein-merge/levenshtein.c
··· 1 + #include <stdio.h> 2 + #include <stdlib.h> 3 + #include <string.h> 4 + 5 + static int min3(int a, int b, int c) { 6 + if (a < b) return a < c ? a : c; 7 + return b < c ? b : c; 8 + } 9 + 10 + static int distance(const char *a, const char *b) { 11 + size_t la = strlen(a), lb = strlen(b); 12 + int *prev = calloc(lb + 1, sizeof(int)); 13 + if (!prev) exit(1); 14 + for (size_t j = 0; j <= lb; j++) prev[j] = (int)j; 15 + for (size_t i = 1; i <= la; i++) { 16 + int corner = prev[0]; 17 + prev[0] = (int)i; 18 + for (size_t j = 1; j <= lb; j++) { 19 + int val = min3(prev[j] + 1, prev[j-1] + 1, 20 + corner + (a[i-1] != b[j-1])); 21 + corner = prev[j]; 22 + prev[j] = val; 23 + } 24 + } 25 + int res = prev[lb]; 26 + free(prev); 27 + return res; 28 + } 29 + 30 + /* Collapse whitespace and trim. Returns malloc'd string. */ 31 + static char *normalize(const char *s) { 32 + while (*s == ' ' || *s == '\t') s++; 33 + size_t len = strlen(s); 34 + char *out = malloc(len + 1); 35 + if (!out) exit(1); 36 + size_t o = 0; 37 + int in_space = 0; 38 + for (size_t i = 0; i < len; i++) { 39 + if (s[i] == ' ' || s[i] == '\t') { 40 + if (!in_space && o > 0) out[o++] = ' '; 41 + in_space = 1; 42 + } else { 43 + out[o++] = s[i]; 44 + in_space = 0; 45 + } 46 + } 47 + if (o > 0 && out[o-1] == ' ') o--; 48 + out[o] = '\0'; 49 + return out; 50 + } 51 + 52 + static int fuzzy_match(const char *a, const char *b, double threshold) { 53 + int dist = distance(a, b); 54 + size_t la = strlen(a), lb = strlen(b); 55 + size_t m = la > lb ? la : lb; 56 + if (m == 0) return 1; 57 + return (double)dist / (double)m <= threshold; 58 + } 59 + 60 + /* Dynamic line array */ 61 + struct lines { char **l; size_t n, cap; }; 62 + 63 + static void lines_init(struct lines *v) { 64 + v->n = 0; v->cap = 64; 65 + v->l = malloc(v->cap * sizeof(char *)); 66 + if (!v->l) exit(1); 67 + } 68 + 69 + static void lines_push(struct lines *v, char *s) { 70 + if (v->n == v->cap) { 71 + v->cap *= 2; 72 + v->l = realloc(v->l, v->cap * sizeof(char *)); 73 + if (!v->l) exit(1); 74 + } 75 + v->l[v->n++] = s; 76 + } 77 + 78 + static void read_lines(const char *path, struct lines *v) { 79 + FILE *f = fopen(path, "r"); 80 + if (!f) return; 81 + char *line = NULL; 82 + size_t cap = 0; 83 + ssize_t len; 84 + while ((len = getline(&line, &cap, f)) != -1) { 85 + if (len > 0 && line[len-1] == '\n') line[len-1] = '\0'; 86 + lines_push(v, strdup(line)); 87 + } 88 + free(line); 89 + fclose(f); 90 + } 91 + 92 + int main(int argc, char **argv) { 93 + if (argc == 3) { 94 + printf("%d\n", distance(argv[1], argv[2])); 95 + return 0; 96 + } 97 + if (argc == 5 && strcmp(argv[1], "-t") == 0) { 98 + double t = atof(argv[2]); 99 + return fuzzy_match(argv[3], argv[4], t) ? 0 : 1; 100 + } 101 + if (argc == 5 && strcmp(argv[1], "merge") == 0) { 102 + double threshold = atof(argv[4]); 103 + 104 + struct lines target, managed, known; 105 + lines_init(&target); 106 + lines_init(&managed); 107 + lines_init(&known); 108 + 109 + read_lines(argv[3], &target); 110 + read_lines(argv[2], &managed); 111 + 112 + /* Build normalized index of target lines */ 113 + for (size_t i = 0; i < target.n; i++) { 114 + char *n = normalize(target.l[i]); 115 + if (n[0]) lines_push(&known, n); 116 + else free(n); 117 + } 118 + 119 + /* Find managed lines missing from target */ 120 + struct lines missing; 121 + lines_init(&missing); 122 + 123 + for (size_t i = 0; i < managed.n; i++) { 124 + char *n = normalize(managed.l[i]); 125 + if (!n[0]) { free(n); continue; } 126 + int found = 0; 127 + for (size_t j = 0; j < known.n; j++) { 128 + if (fuzzy_match(n, known.l[j], threshold)) { 129 + found = 1; 130 + break; 131 + } 132 + } 133 + if (!found) { 134 + lines_push(&missing, managed.l[i]); 135 + lines_push(&known, n); 136 + } else { 137 + free(n); 138 + } 139 + } 140 + 141 + if (missing.n == 0) return 0; 142 + 143 + FILE *f = fopen(argv[3], "a"); 144 + if (!f) { perror(argv[3]); return 1; } 145 + fprintf(f, "\n"); 146 + for (size_t i = 0; i < missing.n; i++) 147 + fprintf(f, "%s\n", missing.l[i]); 148 + fclose(f); 149 + return 0; 150 + } 151 + 152 + fprintf(stderr, 153 + "usage: levenshtein-merge <a> <b>\n" 154 + " levenshtein-merge -t <threshold> <a> <b>\n" 155 + " levenshtein-merge merge <managed> <target> <threshold>\n"); 156 + return 2; 157 + }
+25
pkgs/levenshtein-merge/package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + }: 5 + stdenv.mkDerivation { 6 + pname = "levenshtein-merge"; 7 + version = "0.1.0"; 8 + 9 + src = ./.; 10 + 11 + buildPhase = '' 12 + $CC -O2 -o levenshtein-merge levenshtein.c 13 + ''; 14 + 15 + installPhase = '' 16 + install -Dm755 levenshtein-merge $out/bin/levenshtein-merge 17 + ''; 18 + 19 + meta = { 20 + description = "Fuzzy line-level file merge using Levenshtein distance"; 21 + license = lib.licenses.mit; 22 + mainProgram = "levenshtein-merge"; 23 + platforms = lib.platforms.all; 24 + }; 25 + }
+18
pkgs/libretro-dolphin/disable-background-shaders.patch
··· 1 + diff --git a/Source/Core/VideoBackends/OGL/Render.cpp b/Source/Core/VideoBackends/OGL/Render.cpp 2 + --- a/Source/Core/VideoBackends/OGL/Render.cpp 3 + +++ b/Source/Core/VideoBackends/OGL/Render.cpp 4 + @@ -674,8 +674,14 @@ Renderer::Renderer(std::unique_ptr<GLContext> main_gl_context, float backbuffer_ 5 + g_Config.backend_info.bSupportsComputeShaders && g_ogl_config.bSupportsImageLoadStore; 6 + 7 + // Background compiling is supported only when shared contexts aren't broken. 8 + +#ifdef __LIBRETRO__ 9 + + // Libretro cannot create shared GL contexts via CreateSharedContext(), 10 + + // so disable background shader compilation entirely. 11 + + g_Config.backend_info.bSupportsBackgroundCompiling = false; 12 + +#else 13 + g_Config.backend_info.bSupportsBackgroundCompiling = 14 + !DriverDetails::HasBug(DriverDetails::BUG_SHARED_CONTEXT_SHADER_COMPILATION); 15 + +#endif 16 + 17 + // Program binaries are supported on GL4.1+, ARB_get_program_binary, or ES3. 18 + if (supports_glsl_cache)
+9
pkgs/libretro-dolphin/package.nix
··· 1 + { 2 + libretro, 3 + }: 4 + 5 + libretro.dolphin.overrideAttrs (old: { 6 + patches = (old.patches or [ ]) ++ [ 7 + ./disable-background-shaders.patch 8 + ]; 9 + })
+142
pkgs/moebius/package.nix
··· 1 + { 2 + lib, 3 + stdenv, 4 + buildNpmPackage, 5 + fetchFromGitHub, 6 + makeWrapper, 7 + makeDesktopItem, 8 + copyDesktopItems, 9 + electron_43, 10 + }: 11 + 12 + let 13 + electron = electron_43; 14 + in 15 + buildNpmPackage (finalAttrs: { 16 + pname = "moebius"; 17 + # Upstream's last tagged release (1.0.29) only ships x86_64 binaries; build the 18 + # newer master commit from source so it runs natively on every platform. 19 + version = "1.0.29-unstable-2023-12-18"; 20 + 21 + src = fetchFromGitHub { 22 + owner = "blocktronics"; 23 + repo = "moebius"; 24 + rev = "1f624d62654d614177635ab18a288253c3b917eb"; 25 + hash = "sha256-Uu6vEhbXXiVbUTgC/WeF+SVCf0oLzg4OnGFZIx7SJ6I="; 26 + }; 27 + 28 + npmDepsHash = "sha256-hLZF0FvyAgJVURmU6M4UYFauZz5SLvH2+6W2qDogoXU="; 29 + 30 + # discord-rpc pulls the optional native `register-scheme` git dependency, which 31 + # is only used outside Electron (inside Electron it falls back to 32 + # app.setAsDefaultProtocolClient), so drop it and build a pure-JS closure. 33 + forceGitDeps = true; 34 + npmFlags = [ 35 + "--omit=dev" 36 + "--omit=optional" 37 + ]; 38 + 39 + # We ship the app on nixpkgs' Electron; don't download a private one or run the 40 + # electron-builder packaging step. 41 + env.ELECTRON_SKIP_BINARY_DOWNLOAD = "1"; 42 + dontNpmBuild = true; 43 + 44 + nativeBuildInputs = [ 45 + makeWrapper 46 + ] 47 + ++ lib.optionals stdenv.hostPlatform.isLinux [ copyDesktopItems ]; 48 + 49 + desktopItems = lib.optionals stdenv.hostPlatform.isLinux [ 50 + (makeDesktopItem { 51 + name = "moebius"; 52 + desktopName = "Moebius"; 53 + comment = finalAttrs.meta.description; 54 + exec = "moebius %U"; 55 + icon = "moebius"; 56 + startupWMClass = "Moebius"; 57 + categories = [ "Graphics" ]; 58 + mimeTypes = [ "text/x-ansi" ]; 59 + }) 60 + ]; 61 + 62 + installPhase = '' 63 + runHook preInstall 64 + 65 + app=$out/libexec/moebius 66 + res=$app/resources 67 + mkdir -p "$app" "$out/bin" 68 + cp -r app node_modules package.json "$app/" 69 + 70 + # Sample art and control-character sheets the app loads at runtime. 71 + mkdir -p "$res" 72 + cp -r build/ans "$res/ans" 73 + cp -r build/png "$res/png" 74 + 75 + # The app switches resource paths on app.isPackaged; we run an unpacked tree, 76 + # so pin both branches to the store copy above. 77 + find "$app/app" -name '*.js' | while read -r f; do 78 + substituteInPlace "$f" \ 79 + --replace-quiet 'remote.app.isPackaged' 'true' \ 80 + --replace-quiet 'process.resourcesPath' "\"$res\"" 81 + done 82 + 83 + # @electron/remote 2.1.0 calls the internal features.isViewApiEnabled(), 84 + # removed in modern Electron; guard it (the fix shipped upstream in 2.1.2). 85 + substituteInPlace "$app/node_modules/@electron/remote/dist/src/common/module-names.js" \ 86 + --replace-fail 'if (!features || features.isViewApiEnabled()) {' \ 87 + 'if (!features || !features.isViewApiEnabled || features.isViewApiEnabled()) {' 88 + 89 + makeWrapper ${lib.getExe electron} $out/bin/moebius \ 90 + --add-flags "$app" \ 91 + ${lib.optionalString stdenv.hostPlatform.isLinux '' 92 + --add-flags "\''${NIXOS_OZONE_WL:+\''${WAYLAND_DISPLAY:+--ozone-platform-hint=auto --enable-features=WaylandWindowDecorations --enable-wayland-ime=true}}" 93 + ''} 94 + '' 95 + + lib.optionalString stdenv.hostPlatform.isLinux '' 96 + for size in 16 32 128 512 1024; do 97 + install -Dm644 build/icons/icon_''${size}x''${size}.png \ 98 + $out/share/icons/hicolor/''${size}x''${size}/apps/moebius.png 99 + done 100 + '' 101 + + lib.optionalString stdenv.hostPlatform.isDarwin '' 102 + appdir="$out/Applications/Moebius.app/Contents" 103 + mkdir -p "$appdir/MacOS" "$appdir/Resources" 104 + cp build/icon.icns "$appdir/Resources/moebius.icns" 105 + makeWrapper $out/bin/moebius "$appdir/MacOS/Moebius" 106 + cat > "$appdir/Info.plist" <<EOF 107 + <?xml version="1.0" encoding="UTF-8"?> 108 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 109 + <plist version="1.0"> 110 + <dict> 111 + <key>CFBundleName</key><string>Moebius</string> 112 + <key>CFBundleDisplayName</key><string>Moebius</string> 113 + <key>CFBundleExecutable</key><string>Moebius</string> 114 + <key>CFBundleIdentifier</key><string>org.andyherbert.moebius</string> 115 + <key>CFBundleIconFile</key><string>moebius.icns</string> 116 + <key>CFBundleShortVersionString</key><string>${finalAttrs.version}</string> 117 + <key>CFBundlePackageType</key><string>APPL</string> 118 + <key>LSMinimumSystemVersion</key><string>10.13</string> 119 + <key>NSHighResolutionCapable</key><true/> 120 + </dict> 121 + </plist> 122 + EOF 123 + '' 124 + + '' 125 + runHook postInstall 126 + ''; 127 + 128 + meta = { 129 + description = "Modern ANSI and ASCII art editor with a Photoshop-style half-block brush"; 130 + homepage = "https://blocktronics.github.io/moebius/"; 131 + downloadPage = "https://github.com/blocktronics/moebius/releases"; 132 + license = lib.licenses.asl20; 133 + maintainers = [ ]; 134 + platforms = [ 135 + "x86_64-linux" 136 + "aarch64-linux" 137 + "x86_64-darwin" 138 + "aarch64-darwin" 139 + ]; 140 + mainProgram = "moebius"; 141 + }; 142 + })
+17
pkgs/nm-dmenu/package.nix
··· 1 + { pkgs, lib, ... }: 2 + 3 + (pkgs.writers.writeDashBin "nm-dmenu" '' 4 + export PATH=$PATH:${ 5 + lib.makeBinPath [ 6 + (pkgs.writers.writeDashBin "dmenu" '' 7 + exec ${lib.getExe pkgs.rofi} -dmenu "$@" 8 + '') 9 + pkgs.networkmanagerapplet 10 + pkgs.procps 11 + ] 12 + } 13 + exec ${pkgs.networkmanager_dmenu}/bin/networkmanager_dmenu "$@" 14 + '') 15 + // { 16 + meta.platforms = lib.platforms.linux; 17 + }
+179
pkgs/omnigent/package.nix
··· 1 + { 2 + lib, 3 + fetchPypi, 4 + python313Packages, 5 + }: 6 + let 7 + py = python313Packages; 8 + 9 + # 0.18.1 added a runtime dependency on `websockets` (realtime transport) that 10 + # nixpkgs' derivation does not declare yet, so its wheel fails the 11 + # runtime-deps check. Add it until nixpkgs catches up. 12 + openai-agents = py.openai-agents.overridePythonAttrs (o: { 13 + dependencies = o.dependencies ++ [ py.websockets ]; 14 + }); 15 + 16 + # omnigent-client declares `omnigent` as a dependency, and omnigent declares 17 + # omnigent-client: the two are released in lockstep from the same repo. Break 18 + # the cycle by dropping the back-reference here; the final application below 19 + # provides `omnigent` itself. 20 + omnigent-client = py.buildPythonPackage rec { 21 + pname = "omnigent_client"; 22 + version = "0.9.0"; 23 + format = "wheel"; 24 + 25 + src = fetchPypi { 26 + inherit pname version format; 27 + dist = "py3"; 28 + python = "py3"; 29 + hash = "sha256-NUF16ZzZkVzohOKznfAPVTT1LI09dXP6gZxlWZzmyrM="; 30 + }; 31 + 32 + pythonRemoveDeps = [ "omnigent" ]; 33 + 34 + dependencies = [ 35 + py.httpx 36 + py.pydantic 37 + ]; 38 + 39 + # No pythonImportsCheck: every module imports `omnigent`, which only exists 40 + # in the application closure below. 41 + doCheck = false; 42 + 43 + meta = { 44 + description = "Python client library for the Omnigent server API"; 45 + homepage = "https://omnigent.ai"; 46 + license = lib.licenses.asl20; 47 + }; 48 + }; 49 + 50 + omnigent-ui-sdk = py.buildPythonPackage rec { 51 + pname = "omnigent_ui_sdk"; 52 + version = "0.9.0"; 53 + format = "wheel"; 54 + 55 + src = fetchPypi { 56 + inherit pname version format; 57 + dist = "py3"; 58 + python = "py3"; 59 + hash = "sha256-xr5rtx2Ac/q8GShkeQudzbKDoLGe5d+Jp7XCiXzU7lQ="; 60 + }; 61 + 62 + dependencies = [ 63 + omnigent-client 64 + py.prompt-toolkit 65 + py.pyyaml 66 + py.rich 67 + ]; 68 + 69 + doCheck = false; 70 + 71 + meta = { 72 + description = "Terminal UI SDK for Omnigent"; 73 + homepage = "https://omnigent.ai"; 74 + license = lib.licenses.asl20; 75 + }; 76 + }; 77 + in 78 + py.buildPythonApplication rec { 79 + pname = "omnigent"; 80 + version = "0.9.0"; 81 + format = "wheel"; 82 + 83 + # The published wheel ships the prebuilt web SPA under 84 + # omnigent/server/static/web-ui; the sdist does not. 85 + src = fetchPypi { 86 + inherit pname version format; 87 + dist = "py3"; 88 + python = "py3"; 89 + hash = "sha256-deNx9SHyaf1ZNkq5+88c1fUieFZastIo8N27Ixy0F1Q="; 90 + }; 91 + 92 + # Upper bounds that nixpkgs has already moved past. All of these are 93 + # conservative caps in omnigent's pyproject, not known incompatibilities. 94 + pythonRelaxDeps = [ 95 + "argon2-cffi" # nixpkgs 25.1.0 vs <24 96 + "cachetools" # nixpkgs 7.1.4 vs <7 97 + "openai" # nixpkgs 2.53.0 vs <2.45 98 + "packaging" # nixpkgs 26.2 vs <26 99 + "protobuf" # nixpkgs 7.35.1 vs <7 100 + "rich" # nixpkgs 15.0.0 vs <15 101 + "websockets" # nixpkgs 16.1 vs <15 102 + ]; 103 + 104 + dependencies = [ 105 + omnigent-client 106 + omnigent-ui-sdk 107 + ] 108 + ++ (with py; [ 109 + alembic 110 + anyio 111 + argon2-cffi 112 + cachetools 113 + cel-python 114 + certifi 115 + claude-agent-sdk 116 + click 117 + fastapi 118 + ftfy 119 + httpx 120 + json5 121 + keyring 122 + mcp 123 + openai 124 + openai-agents 125 + opentelemetry-exporter-otlp-proto-grpc 126 + opentelemetry-exporter-otlp-proto-http 127 + opentelemetry-instrumentation-fastapi 128 + opentelemetry-instrumentation-httpx 129 + opentelemetry-instrumentation-sqlalchemy 130 + packaging 131 + pexpect 132 + prompt-toolkit 133 + protobuf 134 + psutil 135 + pydantic 136 + pyjwt 137 + pyte 138 + python-dateutil 139 + pyyaml 140 + rich 141 + sqlalchemy 142 + starlette 143 + tiktoken 144 + tomlkit 145 + uvicorn 146 + websockets 147 + zstandard 148 + ]) 149 + ++ py.pyjwt.optional-dependencies.crypto 150 + ++ py.uvicorn.optional-dependencies.standard; 151 + 152 + # The CLI respawns itself as `sys.executable -m omnigent.host._daemon_entry` 153 + # (cli.py:2945) for the host daemon, and the daemon spawns runners the same 154 + # way. That is the *bare* interpreter, without the site-dirs that 155 + # wrapPythonPrograms bakes into the console script, so those children die 156 + # with ModuleNotFoundError: No module named 'omnigent' and every session hangs 157 + # on "the connect daemon did not come online within 30s". Hand them the 158 + # transitive dependency closure through PYTHONPATH, which omnigent's own 159 + # daemon/runner env allowlist preserves (host/connect.py) while the agent 160 + # spawn allowlist drops it (inner/agent_env.py), so it never reaches omp. 161 + postFixup = '' 162 + buildPythonPath "$out $pythonPath" 163 + wrapProgram $out/bin/omnigent --prefix PYTHONPATH : "$program_PYTHONPATH" 164 + ''; 165 + 166 + pythonImportsCheck = [ 167 + "omnigent" 168 + "omnigent_client" 169 + "omnigent_ui_sdk" 170 + ]; 171 + 172 + meta = { 173 + description = "Open-source meta-harness giving a common orchestration layer over AI coding agents"; 174 + homepage = "https://omnigent.ai"; 175 + license = lib.licenses.asl20; 176 + mainProgram = "omnigent"; 177 + platforms = lib.platforms.unix; 178 + }; 179 + }
+53
pkgs/parley/package.nix
··· 1 + { 2 + lib, 3 + buildGoModule, 4 + fetchFromGitea, 5 + go_1_27, 6 + }: 7 + 8 + # go.mod requires go 1.27; nixpkgs' default go is still 1.26. 9 + (buildGoModule.override { go = go_1_27; }) ( 10 + finalAttrs: 11 + let 12 + rev = "52118a9e2475bd3f8c4d62e3032eaf821a9a239d"; 13 + in 14 + { 15 + pname = "parley"; 16 + version = "0.5.0-unstable-2026-09-27"; 17 + 18 + src = fetchFromGitea { 19 + domain = "git.mills.io"; 20 + owner = "prologic"; 21 + repo = "parley"; 22 + inherit rev; 23 + hash = "sha256-bFeX5Nd8IvtY6Di+YUCZtclGkV2bTdIxTWpivCtRqLM="; 24 + }; 25 + 26 + vendorHash = "sha256-siSavE8i/Rh6LfI+HXhyKsPrbfyncJCcEYJsA4KZ378="; 27 + 28 + # modernc.org/sqlite is pure Go; upstream builds static binaries. 29 + env.CGO_ENABLED = 0; 30 + 31 + # The generated templ components and the compiled stylesheet are 32 + # committed upstream, so neither templ nor tailwind is needed here. 33 + subPackages = [ 34 + "cmd/parleyd" 35 + "cmd/parleyctl" 36 + ]; 37 + 38 + ldflags = [ 39 + "-s" 40 + "-w" 41 + "-X main.Version=${finalAttrs.version}" 42 + "-X main.Commit=${builtins.substring 0 7 rev}" 43 + ]; 44 + 45 + meta = { 46 + description = "Federated, decentralised chat that speaks plain IRC"; 47 + homepage = "https://git.mills.io/prologic/parley"; 48 + license = lib.licenses.mit; 49 + mainProgram = "parleyd"; 50 + platforms = lib.platforms.unix; 51 + }; 52 + } 53 + )
+35
pkgs/phonetpm/package.nix
··· 1 + { 2 + lib, 3 + rustPlatform, 4 + fetchFromGitHub, 5 + }: 6 + 7 + rustPlatform.buildRustPackage rec { 8 + pname = "phonetpm"; 9 + version = "0.1.0"; 10 + 11 + src = fetchFromGitHub { 12 + owner = "Lassulus"; 13 + repo = "phonetpm"; 14 + tag = "v${version}"; 15 + hash = "sha256-8WjjbDDHZeYhtnoj/judDrsWT68Z4lnpySM8rSqP+Ao="; 16 + }; 17 + 18 + cargoHash = "sha256-VNw5Hw8E0ReSJhYVLh6XOzQRSyqfFe3uu0522XOeaoA="; 19 + 20 + # Host side only: `phonetpm` (daemon/pair/keys) and `age-plugin-phone`. 21 + # The workspace also holds the Android core (uniffi cdylib), not needed here. 22 + cargoBuildFlags = [ 23 + "-p" 24 + "phonetpm" 25 + ]; 26 + cargoTestFlags = cargoBuildFlags; 27 + 28 + meta = { 29 + description = "Android phone as a biometric ssh-agent and age backend over iroh"; 30 + homepage = "https://github.com/Lassulus/phonetpm"; 31 + license = lib.licenses.mit; 32 + mainProgram = "phonetpm"; 33 + platforms = lib.platforms.linux; 34 + }; 35 + }
+10
pkgs/pop/package.nix
··· 1 + { pkgs, ... }: 2 + 3 + pkgs.writeScriptBin "pop" '' 4 + #! ${pkgs.bash}/bin/bash 5 + 6 + file=$1 7 + 8 + head -1 $file 9 + sed -i 1d $file 10 + ''
+218
pkgs/sdrpp-brown/package.nix
··· 1 + { 2 + stdenv, 3 + lib, 4 + fetchFromGitHub, 5 + fetchurl, 6 + cmake, 7 + pkg-config, 8 + unzip, 9 + libX11, 10 + glfw, 11 + glew, 12 + fftw, 13 + fftwFloat, 14 + volk, 15 + zstd, 16 + libpulseaudio, 17 + 18 + # Sources 19 + airspy_source ? true, 20 + airspy, 21 + airspyhf_source ? true, 22 + airspyhf, 23 + audio_source ? true, 24 + bladerf_source ? stdenv.hostPlatform.isLinux, 25 + libbladeRF, 26 + file_source ? true, 27 + hackrf_source ? true, 28 + hackrf, 29 + hermes_source ? true, 30 + hl2_source ? true, 31 + kiwisdr_source ? true, 32 + limesdr_source ? true, 33 + limesuite, 34 + network_source ? true, 35 + plutosdr_source ? stdenv.hostPlatform.isLinux, 36 + libiio, 37 + libad9361, 38 + rfspace_source ? true, 39 + rtl_sdr_source ? true, 40 + rtl-sdr-osmocom, 41 + libusb1, 42 + rtl_tcp_source ? true, 43 + sdrplay_source ? false, 44 + sdrplay, 45 + sdrpp_server_source ? true, 46 + soapy_source ? true, 47 + soapysdr-with-plugins, 48 + spectran_http_source ? true, 49 + spyserver_source ? true, 50 + usrp_source ? false, 51 + uhd, 52 + boost, 53 + 54 + # Sinks 55 + audio_sink ? true, 56 + rtaudio, 57 + mpeg_adts_sink ? false, # downloads lame at build time - disable for now 58 + network_sink ? true, 59 + portaudio_sink ? false, 60 + portaudio, 61 + 62 + # Decoders 63 + atv_decoder ? true, 64 + ch_extravhf_decoder ? false, # requires itpp/mbe libraries 65 + ch_tetra_demodulator ? true, 66 + dab_decoder ? false, 67 + falcon9_decoder ? false, 68 + ft8_decoder ? true, 69 + kg_sstv_decoder ? false, 70 + m17_decoder ? false, 71 + codec2, 72 + meteor_demodulator ? true, 73 + pager_decoder ? true, 74 + radio ? true, 75 + weather_sat_decoder ? false, 76 + 77 + # Misc 78 + discord_presence ? true, 79 + frequency_manager ? true, 80 + iq_exporter ? true, 81 + recorder ? true, 82 + rigctl_client ? true, 83 + rigctl_server ? true, 84 + scanner ? true, 85 + noise_reduction ? true, 86 + }: 87 + 88 + let 89 + # ETSI TETRA codec - pre-fetched to avoid network access during build 90 + etsiCodec = fetchurl { 91 + url = "http://www.etsi.org/deliver/etsi_en/300300_300399/30039502/01.03.01_60/en_30039502v010301p0.zip"; 92 + hash = "sha256-H+GMR3PIzLUu8jyltKCwhBs41U3dXZx9huuLBg8TLzk="; 93 + }; 94 + in 95 + stdenv.mkDerivation (_finalAttrs: { 96 + pname = "sdrpp-brown"; 97 + 98 + version = "0-unstable-2025-12-29"; 99 + 100 + src = fetchFromGitHub { 101 + owner = "sannysanoff"; 102 + repo = "SDRPlusPlusBrown"; 103 + rev = "f0feccfb10a9a102255c771ec3f5923c6f58ad9e"; 104 + hash = "sha256-2DeDWoWRTv6lcF+DQyaVx2oJsjlGsUf88MrF3+7T6Ew="; 105 + }; 106 + 107 + nativeBuildInputs = [ 108 + cmake 109 + pkg-config 110 + ] 111 + ++ lib.optional ch_tetra_demodulator unzip; 112 + 113 + buildInputs = [ 114 + glfw 115 + glew 116 + fftw 117 + fftwFloat 118 + volk 119 + zstd 120 + ] 121 + ++ lib.optional stdenv.hostPlatform.isLinux libX11 122 + ++ lib.optional stdenv.hostPlatform.isLinux libpulseaudio 123 + ++ lib.optional airspy_source airspy 124 + ++ lib.optional airspyhf_source airspyhf 125 + ++ lib.optional bladerf_source libbladeRF 126 + ++ lib.optional hackrf_source hackrf 127 + ++ lib.optional limesdr_source limesuite 128 + ++ lib.optionals rtl_sdr_source [ 129 + rtl-sdr-osmocom 130 + libusb1 131 + ] 132 + ++ lib.optional sdrplay_source sdrplay 133 + ++ lib.optional soapy_source soapysdr-with-plugins 134 + ++ lib.optionals plutosdr_source [ 135 + libiio 136 + libad9361 137 + ] 138 + ++ lib.optionals usrp_source [ 139 + uhd 140 + boost 141 + ] 142 + ++ lib.optional (audio_source || audio_sink) rtaudio 143 + ++ lib.optional portaudio_sink portaudio 144 + ++ lib.optional (dab_decoder || m17_decoder) codec2; 145 + 146 + # Pre-place the ETSI codec in the build directory so CMake doesn't try to download it 147 + preConfigure = lib.optionalString ch_tetra_demodulator '' 148 + mkdir -p build/decoder_modules/ch_tetra_demodulator/etsi_codec 149 + cp ${etsiCodec} build/decoder_modules/ch_tetra_demodulator/etsi_codec/etsi_tetra_codec.zip 150 + ''; 151 + 152 + cmakeFlags = [ 153 + # Sources 154 + (lib.cmakeBool "OPT_BUILD_AIRSPYHF_SOURCE" airspyhf_source) 155 + (lib.cmakeBool "OPT_BUILD_AIRSPY_SOURCE" airspy_source) 156 + (lib.cmakeBool "OPT_BUILD_AUDIO_SOURCE" audio_source) 157 + (lib.cmakeBool "OPT_BUILD_BLADERF_SOURCE" bladerf_source) 158 + (lib.cmakeBool "OPT_BUILD_FILE_SOURCE" file_source) 159 + (lib.cmakeBool "OPT_BUILD_HACKRF_SOURCE" hackrf_source) 160 + (lib.cmakeBool "OPT_BUILD_HERMES_SOURCE" hermes_source) 161 + (lib.cmakeBool "OPT_BUILD_HL2_SOURCE" hl2_source) 162 + (lib.cmakeBool "OPT_BUILD_KIWISDR_SOURCE" kiwisdr_source) 163 + (lib.cmakeBool "OPT_BUILD_LIMESDR_SOURCE" limesdr_source) 164 + (lib.cmakeBool "OPT_BUILD_NETWORK_SOURCE" network_source) 165 + (lib.cmakeBool "OPT_BUILD_PLUTOSDR_SOURCE" plutosdr_source) 166 + (lib.cmakeBool "OPT_BUILD_RFSPACE_SOURCE" rfspace_source) 167 + (lib.cmakeBool "OPT_BUILD_RTL_SDR_SOURCE" rtl_sdr_source) 168 + (lib.cmakeBool "OPT_BUILD_RTL_TCP_SOURCE" rtl_tcp_source) 169 + (lib.cmakeBool "OPT_BUILD_SDRPLAY_SOURCE" sdrplay_source) 170 + (lib.cmakeBool "OPT_BUILD_SDRPP_SERVER_SOURCE" sdrpp_server_source) 171 + (lib.cmakeBool "OPT_BUILD_SOAPY_SOURCE" soapy_source) 172 + (lib.cmakeBool "OPT_BUILD_SPECTRAN_HTTP_SOURCE" spectran_http_source) 173 + (lib.cmakeBool "OPT_BUILD_SPYSERVER_SOURCE" spyserver_source) 174 + (lib.cmakeBool "OPT_BUILD_USRP_SOURCE" usrp_source) 175 + 176 + # Sinks 177 + (lib.cmakeBool "OPT_BUILD_AUDIO_SINK" audio_sink) 178 + (lib.cmakeBool "OPT_BUILD_MPEG_ADTS_SINK" mpeg_adts_sink) 179 + (lib.cmakeBool "OPT_BUILD_NETWORK_SINK" network_sink) 180 + (lib.cmakeBool "OPT_BUILD_NEW_PORTAUDIO_SINK" portaudio_sink) 181 + 182 + # Decoders 183 + (lib.cmakeBool "OPT_BUILD_ATV_DECODER" atv_decoder) 184 + (lib.cmakeBool "OPT_BUILD_CH_EXTRAVHF_DECODER" ch_extravhf_decoder) 185 + (lib.cmakeBool "OPT_BUILD_CH_TETRA_DEMODULATOR" ch_tetra_demodulator) 186 + (lib.cmakeBool "OPT_BUILD_DAB_DECODER" dab_decoder) 187 + (lib.cmakeBool "OPT_BUILD_FALCON9_DECODER" falcon9_decoder) 188 + (lib.cmakeBool "OPT_BUILD_FT8_DECODER" ft8_decoder) 189 + (lib.cmakeBool "OPT_BUILD_KG_SSTV_DECODER" kg_sstv_decoder) 190 + (lib.cmakeBool "OPT_BUILD_M17_DECODER" m17_decoder) 191 + (lib.cmakeBool "OPT_BUILD_METEOR_DEMODULATOR" meteor_demodulator) 192 + (lib.cmakeBool "OPT_BUILD_PAGER_DECODER" pager_decoder) 193 + (lib.cmakeBool "OPT_BUILD_RADIO" radio) 194 + (lib.cmakeBool "OPT_BUILD_WEATHER_SAT_DECODER" weather_sat_decoder) 195 + 196 + # Misc 197 + (lib.cmakeBool "OPT_BUILD_DISCORD_PRESENCE" discord_presence) 198 + (lib.cmakeBool "OPT_BUILD_FREQUENCY_MANAGER" frequency_manager) 199 + (lib.cmakeBool "OPT_BUILD_IQ_EXPORTER" iq_exporter) 200 + (lib.cmakeBool "OPT_BUILD_RECORDER" recorder) 201 + (lib.cmakeBool "OPT_BUILD_RIGCTL_CLIENT" rigctl_client) 202 + (lib.cmakeBool "OPT_BUILD_RIGCTL_SERVER" rigctl_server) 203 + (lib.cmakeBool "OPT_BUILD_SCANNER" scanner) 204 + (lib.cmakeBool "OPT_BUILD_NOISE_REDUCTION_LOGMMSE" noise_reduction) 205 + ]; 206 + 207 + env.NIX_CFLAGS_COMPILE = "-fpermissive"; 208 + 209 + hardeningDisable = lib.optional stdenv.cc.isClang "format"; 210 + 211 + meta = { 212 + description = "SDR++ Brown Edition - Cross-Platform SDR Software with KiwiSDR support"; 213 + homepage = "https://github.com/sannysanoff/SDRPlusPlusBrown"; 214 + license = lib.licenses.gpl3Only; 215 + platforms = lib.platforms.linux; 216 + mainProgram = "sdrpp"; 217 + }; 218 + })
+42
pkgs/secretive/package.nix
··· 1 + { 2 + lib, 3 + stdenvNoCC, 4 + fetchzip, 5 + unzip, 6 + }: 7 + 8 + stdenvNoCC.mkDerivation rec { 9 + pname = "secretive"; 10 + version = "2.4.1"; 11 + 12 + src = fetchzip { 13 + url = "https://github.com/maxgoedjen/secretive/releases/download/v${version}/Secretive.zip"; 14 + hash = "sha256-JzG7AWpvo/ivtb5JARNCYIQiLuqFYe6Peg2T444Qv0M="; 15 + stripRoot = false; 16 + }; 17 + 18 + nativeBuildInputs = [ unzip ]; 19 + 20 + dontBuild = true; 21 + dontConfigure = true; 22 + dontFixup = true; 23 + 24 + installPhase = '' 25 + runHook preInstall 26 + 27 + mkdir -p $out/Applications 28 + cp -R Secretive.app $out/Applications/ 29 + 30 + runHook postInstall 31 + ''; 32 + 33 + meta = { 34 + description = "Store SSH keys in the Secure Enclave"; 35 + homepage = "https://github.com/maxgoedjen/secretive"; 36 + changelog = "https://github.com/maxgoedjen/secretive/releases/tag/v${version}"; 37 + license = lib.licenses.mit; 38 + maintainers = with lib.maintainers; [ ]; 39 + platforms = lib.platforms.darwin; 40 + mainProgram = "Secretive"; 41 + }; 42 + }
+320
pkgs/ssh-tpm-agent/client-pid.patch
··· 1 + diff --git a/agent/agent.go b/agent/agent.go 2 + index ac39d24..b1e399b 100644 3 + --- a/agent/agent.go 4 + +++ b/agent/agent.go 5 + @@ -26,6 +26,7 @@ import ( 6 + "github.com/google/go-tpm/tpm2/transport" 7 + "golang.org/x/crypto/ssh" 8 + "golang.org/x/crypto/ssh/agent" 9 + + "golang.org/x/sys/unix" 10 + "golang.org/x/text/cases" 11 + "golang.org/x/text/language" 12 + ) 13 + @@ -39,17 +40,18 @@ var ( 14 + var SSH_TPM_AGENT_ADD = "tpm-add-key" 15 + 16 + type Agent struct { 17 + - mu sync.Mutex 18 + - tpm func() transport.TPMCloser 19 + - op func() ([]byte, error) 20 + - pin func(key.SSHTPMKeys) ([]byte, error) 21 + - listener *net.UnixListener 22 + - quit chan any 23 + - wg sync.WaitGroup 24 + - keyring func() *keyring.ThreadKeyring 25 + - keys []key.SSHTPMKeys 26 + - hierkeys []*key.HierSSHTPMKey 27 + - agents []agent.ExtendedAgent 28 + + mu sync.Mutex 29 + + tpm func() transport.TPMCloser 30 + + op func() ([]byte, error) 31 + + pin func(key.SSHTPMKeys, int) ([]byte, error) 32 + + listener *net.UnixListener 33 + + quit chan any 34 + + wg sync.WaitGroup 35 + + keyring func() *keyring.ThreadKeyring 36 + + keys []key.SSHTPMKeys 37 + + hierkeys []*key.HierSSHTPMKey 38 + + agents []agent.ExtendedAgent 39 + + clientPID int 40 + } 41 + 42 + var _ agent.ExtendedAgent = &Agent{} 43 + @@ -120,8 +122,8 @@ func (a *Agent) signers() ([]ssh.Signer, error) { 44 + s, err := ssh.NewSignerFromSigner(k.Signer( 45 + a.keyring(), a.op, a.tpm, 46 + func(_ *keyfile.TPMKey) ([]byte, error) { 47 + - // Shimming the function to get the correct type 48 + - return a.pin(k) 49 + + // Shimming the function to get the correct type, pass client PID 50 + + return a.pin(k, a.clientPID) 51 + }), 52 + ) 53 + if err != nil { 54 + @@ -260,7 +262,7 @@ func (a *Agent) confirmKeyUse(wantKey []byte, peer string) error { 55 + if peer != "" { 56 + prompt += "\nRequested by " + peer 57 + } 58 + - ok, err := askpass.AskPermission(prompt) 59 + + ok, err := askpass.AskPermission(prompt, a.clientPID) 60 + if err != nil { 61 + slog.Info("askpass confirmation failed", slog.String("error", err.Error())) 62 + return ErrUserDeniedConfirm 63 + @@ -289,6 +291,18 @@ func (c *connAgent) SignWithFlags(key ssh.PublicKey, data []byte, flags agent.Si 64 + } 65 + 66 + func (a *Agent) serveConn(c *net.UnixConn) { 67 + + // Extract peer credentials to get the client PID 68 + + if rc, err := c.SyscallConn(); err == nil { 69 + + rc.Control(func(fd uintptr) { 70 + + if cred, err := unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED); err == nil { 71 + + a.mu.Lock() 72 + + a.clientPID = int(cred.Pid) 73 + + a.mu.Unlock() 74 + + slog.Debug("client connected", slog.Int("pid", int(cred.Pid))) 75 + + } 76 + + }) 77 + + } 78 + + 79 + ca := &connAgent{Agent: a, peer: peerChain(c)} 80 + if err := agent.ServeAgent(ca, c); err != io.EOF { 81 + slog.Info("Agent client connection ended unsuccessfully", slog.String("error", err.Error())) 82 + @@ -524,7 +538,7 @@ func LoadKeys(keyDir string) ([]key.SSHTPMKeys, error) { 83 + return keys, err 84 + } 85 + 86 + -func NewAgent(listener *net.UnixListener, agents []agent.ExtendedAgent, keyring func() *keyring.ThreadKeyring, tpmFetch func() transport.TPMCloser, ownerPassword func() ([]byte, error), pin func(key.SSHTPMKeys) ([]byte, error)) *Agent { 87 + +func NewAgent(listener *net.UnixListener, agents []agent.ExtendedAgent, keyring func() *keyring.ThreadKeyring, tpmFetch func() transport.TPMCloser, ownerPassword func() ([]byte, error), pin func(key.SSHTPMKeys, int) ([]byte, error)) *Agent { 88 + a := &Agent{ 89 + agents: agents, 90 + tpm: tpmFetch, 91 + diff --git a/agent/agent_test.go b/agent/agent_test.go 92 + index acaec3d..189b607 100644 93 + --- a/agent/agent_test.go 94 + +++ b/agent/agent_test.go 95 + @@ -45,7 +45,7 @@ func TestAddKey(t *testing.T) { 96 + // Owner password 97 + func() ([]byte, error) { return []byte(""), nil }, 98 + // PIN Callback 99 + - func(_ key.SSHTPMKeys) ([]byte, error) { return []byte(""), nil }, 100 + + func(_ key.SSHTPMKeys, _ int) ([]byte, error) { return []byte(""), nil }, 101 + ) 102 + defer ag.Stop() 103 + 104 + diff --git a/askpass/askpass.go b/askpass/askpass.go 105 + index 9830586..aefe469 100644 106 + --- a/askpass/askpass.go 107 + +++ b/askpass/askpass.go 108 + @@ -8,6 +8,7 @@ import ( 109 + "log/slog" 110 + "os" 111 + "os/exec" 112 + + "strconv" 113 + "strings" 114 + "syscall" 115 + 116 + @@ -66,7 +67,7 @@ func isTerminal(fd uintptr) bool { 117 + return err == nil 118 + } 119 + 120 + -func ReadPassphrase(prompt string, flags ReadPassFlags) ([]byte, error) { 121 + +func ReadPassphrase(prompt string, flags ReadPassFlags, clientPID int) ([]byte, error) { 122 + var allow_askpass bool 123 + var use_askpass bool 124 + var askpass_hint string 125 + @@ -104,7 +105,7 @@ func ReadPassphrase(prompt string, flags ReadPassFlags) ([]byte, error) { 126 + if (flags & RP_ASK_PERMISSION) != 0 { 127 + askpass_hint = "confirm" 128 + } 129 + - return SshAskPass(prompt, askpass_hint) 130 + + return SshAskPass(prompt, askpass_hint, clientPID) 131 + } 132 + 133 + // If we want to echo stuff, we read directly from stdin 134 + @@ -130,7 +131,7 @@ func ReadPassphrase(prompt string, flags ReadPassFlags) ([]byte, error) { 135 + return pin, nil 136 + } 137 + 138 + -func SshAskPass(prompt, hint string) ([]byte, error) { 139 + +func SshAskPass(prompt, hint string, clientPID int) ([]byte, error) { 140 + var askpass string 141 + var err error 142 + if s, ok := os.LookupEnv("SSH_ASKPASS"); ok { 143 + @@ -144,7 +145,15 @@ func SshAskPass(prompt, hint string) ([]byte, error) { 144 + } 145 + } 146 + 147 + + if clientPID > 0 && os.Getenv("SSH_CLIENT_PID") == "" { 148 + + // Pass the requesting client's PID to pinentry so it can cache per-client. 149 + + os.Setenv("SSH_CLIENT_PID", strconv.Itoa(clientPID)) 150 + + } 151 + + 152 + cmd := exec.Command(askpass, prompt) 153 + + if clientPID > 0 { 154 + + cmd.Env = append(os.Environ(), "SSH_CLIENT_PID="+strconv.Itoa(clientPID)) 155 + + } 156 + if hint != "" { 157 + // Set SSH_ASKPASS_PROMPT only on the subprocess. os.Setenv() would 158 + // leak into subsequent passphrase prompts from the same agent 159 + @@ -173,8 +182,8 @@ func SshAskPass(prompt, hint string) ([]byte, error) { 160 + 161 + // AskPermission runs SSH_ASKPASS with SSH_ASKPASS_PROMPT=confirm set as env. 162 + // It will expect exit code 0 or !0 and return true and false respectively. 163 + -func AskPermission(prompt string) (bool, error) { 164 + - a, err := ReadPassphrase(prompt, RP_USE_ASKPASS|RP_ASK_PERMISSION) 165 + +func AskPermission(prompt string, clientPID int) (bool, error) { 166 + + a, err := ReadPassphrase(prompt, RP_USE_ASKPASS|RP_ASK_PERMISSION, clientPID) 167 + if err != nil { 168 + return false, err 169 + } 170 + diff --git a/cmd/ssh-tpm-agent/main.go b/cmd/ssh-tpm-agent/main.go 171 + index 7c6f397..8e19473 100644 172 + --- a/cmd/ssh-tpm-agent/main.go 173 + +++ b/cmd/ssh-tpm-agent/main.go 174 + @@ -243,7 +243,7 @@ func main() { 175 + // Owner password 176 + func() ([]byte, error) { 177 + if askOwnerPassword { 178 + - return askpass.ReadPassphrase("Enter owner password for TPM", askpass.RP_USE_ASKPASS) 179 + + return askpass.ReadPassphrase("Enter owner password for TPM", askpass.RP_USE_ASKPASS, 0) 180 + } else { 181 + ownerPassword := os.Getenv("SSH_TPM_AGENT_OWNER_PASSWORD") 182 + return []byte(ownerPassword), nil 183 + @@ -253,7 +253,7 @@ func main() { 184 + // PIN Callback with caching 185 + // SSHKeySigner in signer/signer.go resets this value if 186 + // we get a TPMRCAuthFail 187 + - func(key key.SSHTPMKeys) ([]byte, error) { 188 + + func(key key.SSHTPMKeys, clientPID int) ([]byte, error) { 189 + auth, err := agentkeyring.ReadKey(key.Fingerprint()) 190 + switch { 191 + case errors.Is(err, syscall.ENOENT): 192 + @@ -262,7 +262,7 @@ func main() { 193 + case errors.Is(err, syscall.ENOKEY) || errors.Is(err, syscall.EACCES): 194 + keyInfo := fmt.Sprintf("Enter passphrase for (%s): ", key.GetDescription()) 195 + // TODO: askpass should box the byte slice 196 + - userauth, err := askpass.ReadPassphrase(keyInfo, askpass.RP_USE_ASKPASS) 197 + + userauth, err := askpass.ReadPassphrase(keyInfo, askpass.RP_USE_ASKPASS, clientPID) 198 + fmt.Println(err) 199 + if !noCache && err == nil { 200 + slog.Debug("caching userauth for key in keyring", slog.String("fp", key.Fingerprint())) 201 + diff --git a/cmd/ssh-tpm-agent/main_test.go b/cmd/ssh-tpm-agent/main_test.go 202 + index 8335058..89697f2 100644 203 + --- a/cmd/ssh-tpm-agent/main_test.go 204 + +++ b/cmd/ssh-tpm-agent/main_test.go 205 + @@ -144,7 +144,7 @@ func runSSHAuth(t *testing.T, keytype tpm2.TPMAlgID, bits int, pin []byte, keyfn 206 + // Owner password 207 + func() ([]byte, error) { return []byte(""), nil }, 208 + // PIN Callback 209 + - func(_ key.SSHTPMKeys) ([]byte, error) { 210 + + func(_ key.SSHTPMKeys, _ int) ([]byte, error) { 211 + return pin, nil 212 + }, 213 + ) 214 + diff --git a/cmd/ssh-tpm-keygen/main.go b/cmd/ssh-tpm-keygen/main.go 215 + index 4002026..eae8d39 100644 216 + --- a/cmd/ssh-tpm-keygen/main.go 217 + +++ b/cmd/ssh-tpm-keygen/main.go 218 + @@ -89,11 +89,11 @@ Example: 219 + 220 + func getPin() ([]byte, error) { 221 + for { 222 + - pin1, err := askpass.ReadPassphrase("Enter passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 223 + + pin1, err := askpass.ReadPassphrase("Enter passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 224 + if err != nil { 225 + return nil, err 226 + } 227 + - pin2, err := askpass.ReadPassphrase("Enter same passphrase again: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 228 + + pin2, err := askpass.ReadPassphrase("Enter same passphrase again: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 229 + if err != nil { 230 + return nil, err 231 + } 232 + @@ -106,7 +106,7 @@ func getPin() ([]byte, error) { 233 + } 234 + 235 + func getOwnerPassword() ([]byte, error) { 236 + - return askpass.ReadPassphrase("Enter owner password: ", askpass.RP_ALLOW_STDIN) 237 + + return askpass.ReadPassphrase("Enter owner password: ", askpass.RP_ALLOW_STDIN, 0) 238 + } 239 + 240 + func doHostKeys(tpm transport.TPMCloser, outputFile string, ownerPassword []byte, hierarchy string) { 241 + @@ -191,7 +191,7 @@ func doChangePin(tpm transport.TPMCloser, passphrase, keyPin, ownerPassword []by 242 + fmt.Printf("Key has comment '%s'\n", k.Description) 243 + } 244 + if outputFile == "" { 245 + - f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which the key is (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 246 + + f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which the key is (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON, 0) 247 + if err != nil { 248 + return err 249 + } 250 + @@ -202,18 +202,18 @@ func doChangePin(tpm transport.TPMCloser, passphrase, keyPin, ownerPassword []by 251 + } 252 + 253 + if len(passphrase) == 0 { 254 + - passphrase, err = askpass.ReadPassphrase("Enter old passphrase: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 255 + + passphrase, err = askpass.ReadPassphrase("Enter old passphrase: ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 256 + if err != nil { 257 + return err 258 + } 259 + } 260 + 261 + if len(keyPin) == 0 { 262 + - keyPin, err = askpass.ReadPassphrase("Enter new passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 263 + + keyPin, err = askpass.ReadPassphrase("Enter new passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 264 + if err != nil { 265 + return err 266 + } 267 + - newPin, err := askpass.ReadPassphrase("Enter same passphrase: ", askpass.RP_ALLOW_STDIN) 268 + + newPin, err := askpass.ReadPassphrase("Enter same passphrase: ", askpass.RP_ALLOW_STDIN, 0) 269 + if err != nil { 270 + return err 271 + } 272 + @@ -260,7 +260,7 @@ func doWrapWith(supportedECCBitsizes []int, wrap, wrapWith string, keyParentHand 273 + rawKey, err = ssh.ParseRawPrivateKey(pem) 274 + if errors.As(err, &kerr) { 275 + for { 276 + - pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 277 + + pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 278 + if err != nil { 279 + log.Fatal(err) 280 + } 281 + @@ -358,7 +358,7 @@ func supportedECCBitsizes(tpm transport.TPMCloser) []int { 282 + func checkFile(f string) error { 283 + if utils.FileExists(f) { 284 + fmt.Printf("%s already exists.\n", f) 285 + - s, err := askpass.ReadPassphrase("Overwrite (y/n)? ", askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 286 + + s, err := askpass.ReadPassphrase("Overwrite (y/n)? ", askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON, 0) 287 + if err != nil { 288 + return err 289 + } 290 + @@ -378,7 +378,7 @@ func doImportKey(tpm transport.TPMCloser, keyParentHandle tpm2.TPMHandle, ownerP 291 + rawKey, err = ssh.ParseRawPrivateKey(pem) 292 + if errors.As(err, &kerr) { 293 + for { 294 + - pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE) 295 + + pin, err := askpass.ReadPassphrase("Enter existing passphrase (empty for no passphrase): ", askpass.RP_ALLOW_STDIN|askpass.RP_NEWLINE, 0) 296 + if err != nil { 297 + return nil, err 298 + } 299 + @@ -670,7 +670,7 @@ func main() { 300 + // Else create a normal key 301 + fmt.Printf("Generating a sealed public/private %s key pair.\n", keyType) 302 + if outputFile == "" { 303 + - f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which to save the key (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON) 304 + + f, err := askpass.ReadPassphrase(fmt.Sprintf("Enter file in which to save the key (%s): ", filename), askpass.RP_ALLOW_STDIN|askpass.RPP_ECHO_ON, 0) 305 + if err != nil { 306 + log.Fatal(err) 307 + } 308 + diff --git a/internal/keytest/keytest.go b/internal/keytest/keytest.go 309 + index ade4ad8..90fcf77 100644 310 + --- a/internal/keytest/keytest.go 311 + +++ b/internal/keytest/keytest.go 312 + @@ -121,7 +121,7 @@ func NewTestAgent(t *testing.T, tpm transport.TPMCloser) *agent.Agent { 313 + func() *keyring.ThreadKeyring { return &keyring.ThreadKeyring{} }, 314 + func() transport.TPMCloser { return tpm }, 315 + func() ([]byte, error) { return []byte(""), nil }, 316 + - func(_ key.SSHTPMKeys) ([]byte, error) { 317 + + func(_ key.SSHTPMKeys, _ int) ([]byte, error) { 318 + return []byte(""), nil 319 + }, 320 + )
+10
pkgs/ssh-tpm-agent/package.nix
··· 1 + { 2 + ssh-tpm-agent, 3 + }: 4 + 5 + ssh-tpm-agent.overrideAttrs (old: { 6 + patches = (old.patches or [ ]) ++ [ 7 + ./client-pid.patch 8 + ]; 9 + doCheck = false; 10 + })
+39
pkgs/sshify/package.nix
··· 1 + { pkgs }: 2 + # usage: sshify neoprism.r -- curl ifconfig.me 3 + pkgs.writers.writeBashBin "sshify" '' 4 + set -efu 5 + 6 + TMPDIR=$(mktemp -d) 7 + 8 + SSH_ARGS=() 9 + 10 + while [[ "$#" -gt 0 ]]; do 11 + case $1 in 12 + --) 13 + shift 14 + break 15 + ;; 16 + *) 17 + SSH_ARGS+=($1) 18 + ;; 19 + esac 20 + shift 21 + done 22 + 23 + if [[ "$#" -le 0 ]]; then 24 + echo no command specified 25 + exit 1 26 + fi 27 + 28 + RANDOM_HIGH_PORT=$(shuf -i 20000-65000 -n 1) 29 + 30 + cat << EOF >$TMPDIR/proxychains.conf 31 + [ProxyList] 32 + socks4 127.0.0.1 $RANDOM_HIGH_PORT 33 + EOF 34 + 35 + ssh -fNM -S "$TMPDIR/socket" -D "$RANDOM_HIGH_PORT" "''${SSH_ARGS[@]}" 36 + trap "ssh -S $TMPDIR/socket -O exit bla 2>/dev/null; rm -rf $TMPDIR >&2" EXIT 37 + 38 + ${pkgs.proxychains-ng}/bin/proxychains4 -q -f "$TMPDIR/proxychains.conf" "$@" 39 + ''
+27
pkgs/sxiv/package.nix
··· 1 + { nsxiv, writers }: 2 + 3 + writers.writeDashBin "sxiv" '' 4 + set -efu 5 + tmpfile="''${TMPDIR:-/tmp}/nsxiv_pipe_$$" 6 + trap 'rm -f -- $tmpfile' EXIT 7 + 8 + if [ "$#" -eq 0 ]; then 9 + if [ -t 0 ]; then 10 + echo "sxiv: No arguments provided" >&2; exit 1 11 + else 12 + # Consume stdin and put it in the temporal file 13 + cat > "$tmpfile" 14 + fi 15 + fi 16 + 17 + for arg in "$@"; do 18 + # if it's a pipe then drain it to $tmpfile 19 + [ -p "$arg" ] && cat "$arg" > "$tmpfile" 20 + done 21 + 22 + if [ -s "$tmpfile" ]; then 23 + ${nsxiv}/bin/nsxiv -q "$@" "$tmpfile" # -q to silence warnings 24 + else 25 + ${nsxiv}/bin/nsxiv "$@" # fallback 26 + fi 27 + ''
+57
pkgs/system-audio-dump/package.nix
··· 1 + { 2 + lib, 3 + fetchFromGitHub, 4 + swift, 5 + swiftpm, 6 + swiftPackages, 7 + }: 8 + 9 + swiftPackages.stdenv.mkDerivation { 10 + pname = "system-audio-dump"; 11 + version = "0-unstable-2025-09-02"; 12 + 13 + src = fetchFromGitHub { 14 + owner = "sohzm"; 15 + repo = "systemAudioDump"; 16 + rev = "19caa4f6c0661c03a10d1f08c79a11f0b00f251a"; 17 + hash = "sha256-H2TeB7/Meq7EvfW7zLZn5GGFrKwCyZuJvafv61ZWdrA="; 18 + }; 19 + 20 + nativeBuildInputs = [ 21 + swift 22 + swiftpm 23 + ]; 24 + 25 + postPatch = '' 26 + # Downgrade swift-tools-version from 6.0 to 5.10 and macOS from v15 to v14 27 + sed -i 's/swift-tools-version:6.0/swift-tools-version:5.10/' Package.swift 28 + sed -i 's/.macOS(.v15)/.macOS(.v14)/' Package.swift 29 + 30 + # Patch Swift 6 / macOS 15 features in source 31 + # Remove @retroactive attribute (Swift 6 feature for silencing conformance warnings) 32 + sed -i 's/@retroactive //' Sources/SystemAudioDump/main.swift 33 + # Remove captureMicrophone (macOS 15+ only, defaults to false anyway) 34 + sed -i '/captureMicrophone/d' Sources/SystemAudioDump/main.swift 35 + ''; 36 + 37 + buildPhase = '' 38 + runHook preBuild 39 + swift build -c release 40 + runHook postBuild 41 + ''; 42 + 43 + installPhase = '' 44 + runHook preInstall 45 + mkdir -p $out/bin 46 + cp .build/release/SystemAudioDump $out/bin/system-audio-dump 47 + runHook postInstall 48 + ''; 49 + 50 + meta = { 51 + description = "macOS CLI that captures system audio and outputs raw PCM to stdout"; 52 + homepage = "https://github.com/sohzm/systemAudioDump"; 53 + license = lib.licenses.mit; 54 + platforms = lib.platforms.darwin; 55 + mainProgram = "system-audio-dump"; 56 + }; 57 + }
+73
pkgs/timeful/package.nix
··· 1 + { 2 + lib, 3 + applyPatches, 4 + buildGoModule, 5 + buildNpmPackage, 6 + fetchFromGitHub, 7 + makeWrapper, 8 + }: 9 + 10 + let 11 + version = "0-unstable-2026-10-01"; 12 + 13 + # self-host.patch (AGPL-3.0 §13: this directory is the corresponding source 14 + # linked from the footer and the landing page): 15 + # - strips Google Tag Manager, Publift/Primis ads, the paywall and the 16 + # landing page's YouTube/Vimeo embeds and GitHub star button 17 + # - replaces the Google/Outlook/email-OTP sign-in page with GitHub OAuth 18 + # (GET /api/auth/github, GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET) 19 + # - "Schedule" offers a .ics download next to Google Calendar/Outlook, and 20 + # scheduled-event links point at this instance instead of timeful.app 21 + # - makes the listen address configurable via LISTEN_ADDR (upstream 22 + # hardcodes ":3002" on all interfaces) 23 + src = applyPatches { 24 + src = fetchFromGitHub { 25 + owner = "schej-it"; 26 + repo = "timeful.app"; 27 + rev = "23b7e7ead1d279ea5d8f431199a230c2dfbe0c1a"; 28 + hash = "sha256-Yp2dkz4+QZ9pqZXOM4srZsCiu/TGLQXGNZtsRd5Dx+U="; 29 + }; 30 + patches = [ ./self-host.patch ]; 31 + }; 32 + 33 + frontend = buildNpmPackage { 34 + pname = "timeful-frontend"; 35 + inherit version; 36 + src = "${src}/frontend"; 37 + 38 + npmDepsHash = "sha256-eHitoSJUPX2mhlOgGrECCUp0zhKFxgsfxkY7+BmeHak="; 39 + 40 + # No PostHog key and no Google/Microsoft OAuth client baked in; sign-in 41 + # goes through the server-side GitHub flow instead. 42 + installPhase = '' 43 + runHook preInstall 44 + cp -r dist $out 45 + runHook postInstall 46 + ''; 47 + }; 48 + in 49 + buildGoModule { 50 + pname = "timeful"; 51 + inherit version src; 52 + 53 + modRoot = "server"; 54 + subPackages = [ "." ]; 55 + vendorHash = "sha256-dl9JURh6pCB7P+jAWcjlPZwmkKPqV7EaOJZSwJYwcFQ="; 56 + 57 + nativeBuildInputs = [ makeWrapper ]; 58 + 59 + postInstall = '' 60 + mv $out/bin/server $out/bin/timeful 61 + wrapProgram $out/bin/timeful --set-default FRONTEND_DIST ${frontend} 62 + ''; 63 + 64 + passthru = { inherit frontend; }; 65 + 66 + meta = { 67 + description = "Availability poll to find the best time for a group to meet (formerly Schej)"; 68 + homepage = "https://github.com/schej-it/timeful.app"; 69 + license = lib.licenses.agpl3Only; 70 + mainProgram = "timeful"; 71 + platforms = lib.platforms.linux; 72 + }; 73 + }
+898
pkgs/timeful/self-host.patch
··· 1 + diff --git a/frontend/public/index.html b/frontend/public/index.html 2 + index a300a8a4..48cb0e61 100644 3 + --- a/frontend/public/index.html 4 + +++ b/frontend/public/index.html 5 + @@ -1,21 +1,6 @@ 6 + <!DOCTYPE html> 7 + <html lang="en"> 8 + <head> 9 + - <!-- Google Tag Manager --> 10 + - <script> 11 + - ;(function (w, d, s, l, i) { 12 + - w[l] = w[l] || [] 13 + - w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" }) 14 + - var f = d.getElementsByTagName(s)[0], 15 + - j = d.createElement(s), 16 + - dl = l != "dataLayer" ? "&l=" + l : "" 17 + - j.async = true 18 + - j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl 19 + - f.parentNode.insertBefore(j, f) 20 + - })(window, document, "script", "dataLayer", "GTM-M677X6V") 21 + - </script> 22 + - <!-- End Google Tag Manager --> 23 + - <script async src="https://cdn.fuseplatform.net/publift/tags/2/4241/fuse.js"></script> 24 + 25 + <meta charset="utf-8" /> 26 + <meta http-equiv="X-UA-Compatible" content="IE=edge" /> 27 + @@ -59,16 +44,6 @@ 28 + /> 29 + </head> 30 + <body> 31 + - <!-- Google Tag Manager (noscript) --> 32 + - <noscript 33 + - ><iframe 34 + - src="https://www.googletagmanager.com/ns.html?id=GTM-M677X6V" 35 + - height="0" 36 + - width="0" 37 + - style="display: none; visibility: hidden" 38 + - ></iframe 39 + - ></noscript> 40 + - <!-- End Google Tag Manager (noscript) --> 41 + <noscript> 42 + <strong 43 + >We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work 44 + diff --git a/frontend/src/App.vue b/frontend/src/App.vue 45 + index 176178b5..01469b17 100644 46 + --- a/frontend/src/App.vue 47 + +++ b/frontend/src/App.vue 48 + @@ -357,7 +357,10 @@ export default { 49 + this.webviewDialog = true 50 + return 51 + } 52 + - this.$router.push({ name: "sign-in" }) 53 + + this.$router.push({ 54 + + name: "sign-in", 55 + + query: { next: this.$route.fullPath }, 56 + + }) 57 + // this.signInDialog = true 58 + } else { 59 + this.$router.push({ name: "sign-in" }) 60 + diff --git a/frontend/src/components/Footer.vue b/frontend/src/components/Footer.vue 61 + index 35d9a6d5..ed818994 100644 62 + --- a/frontend/src/components/Footer.vue 63 + +++ b/frontend/src/components/Footer.vue 64 + @@ -30,7 +30,7 @@ 65 + </svg> 66 + </a> 67 + <a 68 + - href="https://github.com/schej-it/timeful.app" 69 + + href="https://github.com/Lassulus/superconfig/tree/master/pkgs/timeful" 70 + aria-label="GitHub" 71 + target="_blank" 72 + ><v-icon class="tw-text-white/80 hover:tw-text-white" 73 + diff --git a/frontend/src/components/home/Dashboard.vue b/frontend/src/components/home/Dashboard.vue 74 + index c56eebff..af75f247 100644 75 + --- a/frontend/src/components/home/Dashboard.vue 76 + +++ b/frontend/src/components/home/Dashboard.vue 77 + @@ -8,7 +8,7 @@ 78 + Dashboard 79 + </div> 80 + <div 81 + - v-if="!isPremiumUser" 82 + + v-if="enablePaywall && !isPremiumUser" 83 + class="tw-flex tw-items-baseline tw-gap-2 tw-text-sm tw-font-normal tw-text-very-dark-gray" 84 + > 85 + <div> 86 + @@ -240,7 +240,13 @@ export default { 87 + }, 88 + computed: { 89 + ...mapGetters(["isPremiumUser"]), 90 + - ...mapState(["authUser", "events", "groupsEnabled", "folders"]), 91 + + ...mapState([ 92 + + "authUser", 93 + + "events", 94 + + "groupsEnabled", 95 + + "folders", 96 + + "enablePaywall", 97 + + ]), 98 + orderedFolders() { 99 + return this.folders.sort((a, b) => { 100 + return a.name.localeCompare(b.name) 101 + diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 102 + index 1e028082..d7377fa3 100644 103 + --- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 104 + +++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue 105 + @@ -1048,6 +1048,7 @@ import { 106 + isPremiumUser, 107 + prefersStartOnMonday, 108 + } from "@/utils" 109 + +import { buildIcs, downloadIcs } from "@/utils/ics_utils" 110 + import { 111 + availabilityTypes, 112 + calendarOptionsDefaults, 113 + @@ -1250,11 +1251,7 @@ export default { 114 + ...mapState(["authUser", "overlayAvailabilitiesEnabled"]), 115 + ...mapGetters(["isPremiumUser"]), 116 + showAds() { 117 + - return ( 118 + - !this.ownerIsPremium && 119 + - !this.isPremiumUser && 120 + - this.state !== this.states.SET_SPECIFIC_TIMES 121 + - ) 122 + + return false 123 + }, 124 + /** Returns the width of the right side of the calendar */ 125 + rightSideWidth() { 126 + @@ -2076,7 +2073,7 @@ export default { 127 + } 128 + return `Tap and drag to add your "available" ${daysOrTimes} in green.` 129 + case this.states.SCHEDULE_EVENT: 130 + - return "Tap and drag on the calendar to schedule a Google Calendar event during those times." 131 + + return "Tap and drag on the calendar to schedule an event during those times." 132 + default: 133 + return "" 134 + } 135 + @@ -2092,7 +2089,7 @@ export default { 136 + } 137 + return `Click and drag to add your "available" ${daysOrTimes} in green.` 138 + case this.states.SCHEDULE_EVENT: 139 + - return "Click and drag on the calendar to schedule a Google Calendar event during those times." 140 + + return "Click and drag on the calendar to schedule an event during those times." 141 + default: 142 + return "" 143 + } 144 + @@ -3563,8 +3560,11 @@ export default { 145 + this.state = this.defaultState 146 + }, 147 + 148 + - /** Redirect user to Google Calendar to finish the creation of the event */ 149 + - confirmScheduleEvent(googleCalendar = true) { 150 + + /** 151 + + * Finish scheduling the dragged-out slot: open Google Calendar / Outlook 152 + + * prefilled, or download an .ics file. target: "google" | "outlook" | "ics" 153 + + */ 154 + + confirmScheduleEvent(target = "google") { 155 + if (!this.curScheduledEvent) return 156 + // if (!isPremiumUser(this.authUser)) { 157 + // this.showUpgradeDialog({ 158 + @@ -3615,9 +3615,27 @@ export default { 159 + const emailsString = encodeURIComponent(emails.filter(Boolean).join(",")) 160 + 161 + const eventId = this.event.shortId ?? this.event._id 162 + + const eventUrl = `${window.location.origin}/e/${eventId}` 163 + + const description = `\n\nThis event was scheduled with Timeful: ${eventUrl}` 164 + + 165 + + if (target === "ics") { 166 + + const ics = buildIcs({ 167 + + uid: `${this.event._id}-${startDate.getTime()}@${window.location.host}`, 168 + + start: startDate, 169 + + end: endDate, 170 + + summary: this.event.name, 171 + + description: description.trim(), 172 + + location: this.event.location, 173 + + url: eventUrl, 174 + + }) 175 + + const filename = `${this.event.name.replace(/[^\w-]+/g, "_") || "event"}.ics` 176 + + downloadIcs(filename, ics) 177 + + this.state = this.defaultState 178 + + return 179 + + } 180 + 181 + let url = "" 182 + - if (googleCalendar) { 183 + + if (target === "google") { 184 + // Format start and end date to be in the format required by gcal (remove -, :, and .000) 185 + const start = startDate.toISOString().replace(/([-:]|\.000)/g, "") 186 + const end = endDate.toISOString().replace(/([-:]|\.000)/g, "") 187 + @@ -3626,14 +3644,13 @@ export default { 188 + url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent( 189 + this.event.name 190 + )}&dates=${start}/${end}&details=${encodeURIComponent( 191 + - "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" 192 + - )}${eventId}&ctz=${this.curTimezone.value}&add=${emailsString}` 193 + + description 194 + + )}&ctz=${this.curTimezone.value}&add=${emailsString}` 195 + } else { 196 + url = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent( 197 + this.event.name 198 + )}&body=${encodeURIComponent( 199 + - "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" + 200 + - eventId 201 + + description 202 + )}&startdt=${startDate.toISOString()}&enddt=${endDate.toISOString()}&location=${encodeURIComponent( 203 + this.event.location || "" 204 + )}&path=/calendar/action/compose&timezone=${this.curTimezone.value}` 205 + diff --git a/frontend/src/components/schedule_overlap/ToolRow.vue b/frontend/src/components/schedule_overlap/ToolRow.vue 206 + index d8aaaddd..5822dd28 100644 207 + --- a/frontend/src/components/schedule_overlap/ToolRow.vue 208 + +++ b/frontend/src/components/schedule_overlap/ToolRow.vue 209 + @@ -118,7 +118,9 @@ 210 + </v-btn> 211 + </template> 212 + <v-list dense> 213 + - <v-list-item @click="(e) => $emit('confirmScheduleEvent', true)"> 214 + + <v-list-item 215 + + @click="(e) => $emit('confirmScheduleEvent', 'google')" 216 + + > 217 + <v-img 218 + src="@/assets/gcal_logo.png" 219 + class="tw-mr-2 tw-flex-none" 220 + @@ -129,7 +131,9 @@ 221 + <v-list-item-title>Google Calendar</v-list-item-title> 222 + </v-list-item-content> 223 + </v-list-item> 224 + - <v-list-item @click="(e) => $emit('confirmScheduleEvent', false)"> 225 + + <v-list-item 226 + + @click="(e) => $emit('confirmScheduleEvent', 'outlook')" 227 + + > 228 + <v-img 229 + src="@/assets/outlook_logo.svg" 230 + class="tw-mr-2 tw-flex-none" 231 + @@ -140,6 +144,14 @@ 232 + <v-list-item-title>Outlook</v-list-item-title> 233 + </v-list-item-content> 234 + </v-list-item> 235 + + <v-list-item @click="(e) => $emit('confirmScheduleEvent', 'ics')"> 236 + + <v-icon class="tw-mr-2 tw-flex-none" size="20" 237 + + >mdi-calendar-export</v-icon 238 + + > 239 + + <v-list-item-content> 240 + + <v-list-item-title>Download .ics</v-list-item-title> 241 + + </v-list-item-content> 242 + + </v-list-item> 243 + </v-list> 244 + </v-menu> 245 + </template> 246 + diff --git a/frontend/src/main.js b/frontend/src/main.js 247 + index 2d370f7a..9dfa971a 100644 248 + --- a/frontend/src/main.js 249 + +++ b/frontend/src/main.js 250 + @@ -5,19 +5,12 @@ import router from "./router" 251 + import store from "./store" 252 + import vuetify from "./plugins/vuetify" 253 + import posthogPlugin from "./plugins/posthog" 254 + -import VueGtm from "@gtm-support/vue2-gtm" 255 + import VueMeta from "vue-meta" 256 + import "./index.css" 257 + 258 + // Posthog 259 + Vue.use(posthogPlugin) 260 + 261 + -// Google Analytics 262 + -Vue.use(VueGtm, { 263 + - id: "GTM-M677X6V", 264 + - vueRouter: router, 265 + -}) 266 + - 267 + // Site Metadata 268 + Vue.use(VueMeta) 269 + 270 + diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js 271 + index a0770841..b9460a8f 100644 272 + --- a/frontend/src/store/index.js 273 + +++ b/frontend/src/store/index.js 274 + @@ -29,7 +29,7 @@ export default new Vuex.Store({ 275 + signUpFormEnabled: false, 276 + daysOnlyEnabled: true, 277 + overlayAvailabilitiesEnabled: true, 278 + - enablePaywall: true, 279 + + enablePaywall: false, 280 + 281 + // Experiments 282 + pricingPageConversion: "control", 283 + diff --git a/frontend/src/utils/ics_utils.js b/frontend/src/utils/ics_utils.js 284 + new file mode 100644 285 + index 00000000..c285bd1b 286 + --- /dev/null 287 + +++ b/frontend/src/utils/ics_utils.js 288 + @@ -0,0 +1,69 @@ 289 + +/** Builds and downloads a single-event iCalendar (RFC 5545) file. */ 290 + + 291 + +const utf8 = new TextEncoder() 292 + + 293 + +/** Escapes a TEXT property value (RFC 5545 §3.3.11) */ 294 + +const escapeText = (text) => 295 + + text 296 + + .replace(/\\/g, "\\\\") 297 + + .replace(/;/g, "\\;") 298 + + .replace(/,/g, "\\,") 299 + + .replace(/\r?\n/g, "\\n") 300 + + 301 + +/** Formats a Date as a UTC DATE-TIME, e.g. 20261020T070000Z */ 302 + +const formatDateTime = (date) => 303 + + date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "") 304 + + 305 + +/** Folds a content line so no physical line exceeds 75 octets (RFC 5545 §3.1) */ 306 + +const foldLine = (line) => { 307 + + const parts = [] 308 + + let current = "" 309 + + let currentBytes = 0 310 + + for (const char of line) { 311 + + const charBytes = utf8.encode(char).length 312 + + // continuation lines start with a space, which counts toward the limit 313 + + const limit = parts.length === 0 ? 75 : 74 314 + + if (currentBytes + charBytes > limit) { 315 + + parts.push(current) 316 + + current = "" 317 + + currentBytes = 0 318 + + } 319 + + current += char 320 + + currentBytes += charBytes 321 + + } 322 + + parts.push(current) 323 + + return parts.join("\r\n ") 324 + +} 325 + + 326 + +export const buildIcs = ({ uid, start, end, summary, description, location, url }) => { 327 + + const lines = [ 328 + + "BEGIN:VCALENDAR", 329 + + "VERSION:2.0", 330 + + "PRODID:-//timeful//self-hosted//EN", 331 + + "CALSCALE:GREGORIAN", 332 + + "METHOD:PUBLISH", 333 + + "BEGIN:VEVENT", 334 + + `UID:${uid}`, 335 + + `DTSTAMP:${formatDateTime(new Date())}`, 336 + + `DTSTART:${formatDateTime(start)}`, 337 + + `DTEND:${formatDateTime(end)}`, 338 + + `SUMMARY:${escapeText(summary)}`, 339 + + ] 340 + + if (description) lines.push(`DESCRIPTION:${escapeText(description)}`) 341 + + if (location) lines.push(`LOCATION:${escapeText(location)}`) 342 + + if (url) lines.push(`URL:${url}`) 343 + + lines.push("END:VEVENT", "END:VCALENDAR") 344 + + return lines.map(foldLine).join("\r\n") + "\r\n" 345 + +} 346 + + 347 + +export const downloadIcs = (filename, icsText) => { 348 + + const blob = new Blob([icsText], { type: "text/calendar;charset=utf-8" }) 349 + + const href = URL.createObjectURL(blob) 350 + + const a = document.createElement("a") 351 + + a.href = href 352 + + a.download = filename 353 + + document.body.appendChild(a) 354 + + a.click() 355 + + a.remove() 356 + + URL.revokeObjectURL(href) 357 + +} 358 + diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue 359 + index 81887280..87dba8d1 100644 360 + --- a/frontend/src/views/Event.vue 361 + +++ b/frontend/src/views/Event.vue 362 + @@ -463,13 +463,29 @@ 363 + Cancel 364 + </v-btn> 365 + <v-spacer /> 366 + - <v-btn 367 + - :disabled="!allowScheduleEvent" 368 + - class="tw-bg-white tw-text-blue" 369 + - @click="confirmScheduleEvent" 370 + - > 371 + - Schedule 372 + - </v-btn> 373 + + <v-menu top offset-y> 374 + + <template v-slot:activator="{ on, attrs }"> 375 + + <v-btn 376 + + :disabled="!allowScheduleEvent" 377 + + class="tw-bg-white tw-text-blue" 378 + + v-bind="attrs" 379 + + v-on="on" 380 + + > 381 + + Schedule 382 + + </v-btn> 383 + + </template> 384 + + <v-list dense> 385 + + <v-list-item @click="confirmScheduleEvent('google')"> 386 + + <v-list-item-title>Google Calendar</v-list-item-title> 387 + + </v-list-item> 388 + + <v-list-item @click="confirmScheduleEvent('outlook')"> 389 + + <v-list-item-title>Outlook</v-list-item-title> 390 + + </v-list-item> 391 + + <v-list-item @click="confirmScheduleEvent('ics')"> 392 + + <v-list-item-title>Download .ics</v-list-item-title> 393 + + </v-list-item> 394 + + </v-list> 395 + + </v-menu> 396 + </template> 397 + </div> 398 + <PubliftAd 399 + @@ -638,11 +654,7 @@ export default { 400 + ...mapState(["authUser", "events"]), 401 + ...mapGetters(["isPremiumUser"]), 402 + showAds() { 403 + - return ( 404 + - !this.ownerIsPremium && 405 + - !this.isPremiumUser && 406 + - !this.isSettingSpecificTimes 407 + - ) 408 + + return false 409 + }, 410 + allowScheduleEvent() { 411 + return this.scheduleOverlapComponent?.allowScheduleEvent 412 + @@ -998,8 +1010,8 @@ export default { 413 + cancelScheduleEvent() { 414 + this.scheduleOverlapComponent?.cancelScheduleEvent() 415 + }, 416 + - confirmScheduleEvent() { 417 + - this.scheduleOverlapComponent?.confirmScheduleEvent() 418 + + confirmScheduleEvent(target) { 419 + + this.scheduleOverlapComponent?.confirmScheduleEvent(target) 420 + }, 421 + 422 + highlightAvailabilityBtn() { 423 + diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue 424 + index b8a50d8f..2c47ac54 100644 425 + --- a/frontend/src/views/Landing.vue 426 + +++ b/frontend/src/views/Landing.vue 427 + @@ -31,13 +31,10 @@ 428 + class="tw-mb-4 tw-flex tw-select-none tw-items-center tw-rounded-full tw-border tw-border-light-gray-stroke tw-bg-white/70 tw-px-2.5 tw-py-1.5 tw-text-sm tw-text-dark-gray" 429 + > 430 + We're open source! 431 + - <github-button 432 + - v-once 433 + - class="-tw-mb-1 tw-ml-2" 434 + - href="https://github.com/schej-it/timeful.app" 435 + - data-show-count="true" 436 + - aria-label="Star timeful.app on GitHub" 437 + - >Star</github-button 438 + + <a 439 + + class="tw-ml-2 tw-text-blue" 440 + + href="https://github.com/Lassulus/superconfig/tree/master/pkgs/timeful" 441 + + >Source</a 442 + > 443 + </div> 444 + <div 445 + @@ -111,20 +108,6 @@ 446 + transition="fade-transition" 447 + contain 448 + /> 449 + - <vue-vimeo-player 450 + - video-url="https://player.vimeo.com/video/1083205305?h=d58bef862a" 451 + - :player-width="800" 452 + - :player-height="800" 453 + - :options="{ 454 + - muted: true, 455 + - playsinline: true, 456 + - responsive: true, 457 + - }" 458 + - :controls="false" 459 + - :autoplay="true" 460 + - :loop="true" 461 + - @play="onPlay" 462 + - /> 463 + </div> 464 + </div> 465 + </div> 466 + @@ -168,25 +151,6 @@ 467 + /> 468 + </div> 469 + 470 + - <!-- Video --> 471 + - <div 472 + - class="tw-flex tw-justify-center tw-bg-green tw-px-4 tw-pb-12 tw-pt-24 md:tw-pb-16" 473 + - > 474 + - <div 475 + - class="tw-h-[300px] tw-max-w-3xl tw-flex-1 sm:tw-h-[400px] md:tw-h-[450px]" 476 + - > 477 + - <iframe 478 + - class="tw-h-full tw-w-full" 479 + - src="https://www.youtube.com/embed/vFkBC8BrkOk?si=pF64JAIyDhom_1do" 480 + - title="Timeful demo" 481 + - frameborder="0" 482 + - allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" 483 + - referrerpolicy="strict-origin-when-cross-origin" 484 + - allowfullscreen 485 + - ></iframe> 486 + - </div> 487 + - </div> 488 + - 489 + <!-- Reddit Testimonials --> 490 + <div class="tw-flex tw-justify-center tw-bg-light-gray tw-py-12"> 491 + <div class="tw-mx-4 tw-max-w-3xl tw-flex-1 sm:tw-mx-16"> 492 + @@ -299,11 +263,9 @@ import NewEvent from "@/components/NewEvent.vue" 493 + import NewDialog from "@/components/NewDialog.vue" 494 + import LandingPageHeader from "@/components/landing/LandingPageHeader.vue" 495 + import Logo from "@/components/Logo.vue" 496 + -import GithubButton from "vue-github-button" 497 + import SignInDialog from "@/components/SignInDialog.vue" 498 + import { calendarTypes } from "@/constants" 499 + import HowItWorksDialog from "@/components/HowItWorksDialog.vue" 500 + -import { vueVimeoPlayer } from "vue-vimeo-player" 501 + import Footer from "@/components/Footer.vue" 502 + import PronunciationMenu from "@/components/PronunciationMenu.vue" 503 + import { mapState, mapMutations } from "vuex" 504 + @@ -325,11 +287,9 @@ export default { 505 + NewEvent, 506 + NewDialog, 507 + LandingPageHeader, 508 + - GithubButton, 509 + Logo, 510 + SignInDialog, 511 + HowItWorksDialog, 512 + - vueVimeoPlayer, 513 + Footer, 514 + PronunciationMenu, 515 + AuthUserMenu, 516 + diff --git a/frontend/src/views/SignIn.vue b/frontend/src/views/SignIn.vue 517 + index d667be55..6be32f29 100644 518 + --- a/frontend/src/views/SignIn.vue 519 + +++ b/frontend/src/views/SignIn.vue 520 + @@ -32,70 +32,20 @@ 521 + </v-card-title> 522 + <v-card-text class="tw-flex tw-flex-col tw-items-center tw-pt-6"> 523 + <div class="tw-mb-4 tw-flex tw-w-full tw-flex-col tw-gap-y-2"> 524 + - <v-btn 525 + - block 526 + - @click="signIn(calendarTypes.GOOGLE)" 527 + - class="tw-bg-white" 528 + - > 529 + + <v-btn block @click="signInGithub" class="tw-bg-white"> 530 + <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 531 + - <v-img 532 + - class="tw-flex-initial" 533 + - width="20" 534 + - height="20" 535 + - src="@/assets/google_logo.svg" 536 + - /> 537 + + <v-icon class="tw-flex-initial" size="20">mdi-github</v-icon> 538 + <v-spacer /> 539 + - {{ isSignUp ? "Sign up with" : "Continue with" }} Google 540 + + {{ isSignUp ? "Sign up with" : "Continue with" }} GitHub 541 + <v-spacer /> 542 + </div> 543 + </v-btn> 544 + - <v-btn 545 + - block 546 + - @click="signIn(calendarTypes.OUTLOOK)" 547 + - class="tw-bg-white" 548 + + <div 549 + + v-if="$route.query.error === 'github'" 550 + + class="tw-text-center tw-text-sm tw-text-red" 551 + > 552 + - <div class="tw-flex tw-w-full tw-items-center tw-gap-2"> 553 + - <v-img 554 + - class="tw-flex-initial" 555 + - width="20" 556 + - height="20" 557 + - src="@/assets/outlook_logo.svg" 558 + - /> 559 + - <v-spacer /> 560 + - {{ isSignUp ? "Sign up with" : "Continue with" }} Outlook 561 + - <v-spacer /> 562 + - </div> 563 + - </v-btn> 564 + - 565 + - <div class="tw-my-2 tw-flex tw-items-center tw-gap-3"> 566 + - <v-divider /> 567 + - <span class="tw-text-xs tw-text-gray">or</span> 568 + - <v-divider /> 569 + - </div> 570 + - 571 + - <div> 572 + - <div class="tw-mb-1 tw-text-sm tw-font-medium"> 573 + - Email address 574 + - </div> 575 + - <v-text-field 576 + - v-model="email" 577 + - class="tw-mb-2" 578 + - placeholder="Enter your email..." 579 + - type="email" 580 + - solo 581 + - hide-details="auto" 582 + - :error-messages="emailError" 583 + - @keydown.enter="submitEmail" 584 + - /> 585 + - <v-btn 586 + - block 587 + - color="primary" 588 + - :loading="sending" 589 + - :disabled="sending" 590 + - @click="submitEmail" 591 + - > 592 + - {{ isSignUp ? "Sign up with" : "Continue with" }} Email 593 + - </v-btn> 594 + + GitHub sign-in failed. Make sure your GitHub account has a 595 + + verified email address and try again. 596 + </div> 597 + </div> 598 + <div class="tw-text-center tw-text-xs"> 599 + @@ -251,7 +201,7 @@ 600 + 601 + <script> 602 + import { authTypes, calendarTypes } from "@/constants" 603 + -import { post, signInGoogle, signInOutlook } from "@/utils" 604 + +import { getEventsCreated, post, signInGoogle, signInOutlook } from "@/utils" 605 + import { mapMutations } from "vuex" 606 + import Logo from "@/components/Logo.vue" 607 + 608 + @@ -299,6 +249,15 @@ export default { 609 + 610 + methods: { 611 + ...mapMutations(["setAuthUser"]), 612 + + signInGithub() { 613 + + const params = new URLSearchParams({ 614 + + timezoneOffset: new Date().getTimezoneOffset(), 615 + + eventsToLink: getEventsCreated().join(","), 616 + + }) 617 + + const next = this.$route.query.next 618 + + if (typeof next === "string") params.set("redirect", next) 619 + + window.location.href = `/api/auth/github?${params}` 620 + + }, 621 + signIn(provider) { 622 + const state = this.upgradeRedirect 623 + ? { type: authTypes.UPGRADE, upgradeParams: this.$route.query.upgradeParams } 624 + diff --git a/server/main.go b/server/main.go 625 + index b038861e..1678b7df 100644 626 + --- a/server/main.go 627 + +++ b/server/main.go 628 + @@ -175,7 +175,9 @@ func main() { 629 + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) 630 + 631 + // Run server 632 + - if os.Getenv("NODE_ENV") == "staging" { 633 + + if listenAddr := os.Getenv("LISTEN_ADDR"); listenAddr != "" { 634 + + router.Run(listenAddr) 635 + + } else if os.Getenv("NODE_ENV") == "staging" { 636 + router.Run(":3003") 637 + } else { 638 + router.Run(":3002") 639 + diff --git a/server/routes/auth.go b/server/routes/auth.go 640 + index 6444e714..2869116d 100644 641 + --- a/server/routes/auth.go 642 + +++ b/server/routes/auth.go 643 + @@ -41,6 +41,8 @@ func InitAuth(router *gin.RouterGroup) { 644 + authRouter.POST("/otp/check-email", checkEmail) 645 + authRouter.POST("/otp/send", sendOtp) 646 + authRouter.POST("/otp/verify", verifyOtp) 647 + + 648 + + initGithubAuth(authRouter) 649 + } 650 + 651 + // @Summary Signs user in 652 + diff --git a/server/routes/github_auth.go b/server/routes/github_auth.go 653 + new file mode 100644 654 + index 00000000..40ef9206 655 + --- /dev/null 656 + +++ b/server/routes/github_auth.go 657 + @@ -0,0 +1,241 @@ 658 + +/* GitHub OAuth sign-in (self-host patch). Accounts are keyed by the GitHub 659 + + * account's primary verified email, like the email OTP flow, and carry no 660 + + * calendar access. */ 661 + +package routes 662 + + 663 + +import ( 664 + + "context" 665 + + "crypto/rand" 666 + + "encoding/hex" 667 + + "encoding/json" 668 + + "fmt" 669 + + "net/http" 670 + + "net/url" 671 + + "os" 672 + + "strconv" 673 + + "strings" 674 + + "time" 675 + + 676 + + "github.com/gin-contrib/sessions" 677 + + "github.com/gin-gonic/gin" 678 + + "go.mongodb.org/mongo-driver/bson" 679 + + "go.mongodb.org/mongo-driver/bson/primitive" 680 + + "schej.it/server/db" 681 + + "schej.it/server/logger" 682 + + "schej.it/server/models" 683 + + "schej.it/server/utils" 684 + +) 685 + + 686 + +var githubHttpClient = &http.Client{Timeout: 10 * time.Second} 687 + + 688 + +// Session keys holding the in-flight sign-in between the redirect to GitHub 689 + +// and the callback. 690 + +const ( 691 + + githubStateKey = "githubState" 692 + + githubRedirectKey = "githubRedirect" 693 + + githubTzKey = "githubTimezoneOffset" 694 + + githubLinkKey = "githubEventsToLink" 695 + +) 696 + + 697 + +func initGithubAuth(authRouter *gin.RouterGroup) { 698 + + authRouter.GET("/github", githubSignIn) 699 + + authRouter.GET("/github/callback", githubCallback) 700 + +} 701 + + 702 + +// @Summary Starts GitHub sign-in 703 + +// @Description Redirects to GitHub's OAuth consent page. Query: redirect (relative path to return to), timezoneOffset, eventsToLink (comma-separated event ids created while signed out) 704 + +// @Tags auth 705 + +// @Router /auth/github [get] 706 + +func githubSignIn(c *gin.Context) { 707 + + clientId := os.Getenv("GITHUB_CLIENT_ID") 708 + + if clientId == "" { 709 + + c.String(http.StatusNotFound, "GitHub sign-in is not configured") 710 + + return 711 + + } 712 + + 713 + + stateBytes := make([]byte, 32) 714 + + if _, err := rand.Read(stateBytes); err != nil { 715 + + logger.StdErr.Panicln(err) 716 + + } 717 + + state := hex.EncodeToString(stateBytes) 718 + + 719 + + session := sessions.Default(c) 720 + + session.Set(githubStateKey, state) 721 + + session.Set(githubRedirectKey, safeRedirectPath(c.Query("redirect"))) 722 + + session.Set(githubTzKey, c.Query("timezoneOffset")) 723 + + session.Set(githubLinkKey, c.Query("eventsToLink")) 724 + + session.Save() 725 + + 726 + + params := url.Values{} 727 + + params.Set("client_id", clientId) 728 + + params.Set("scope", "read:user user:email") 729 + + params.Set("state", state) 730 + + c.Redirect(http.StatusFound, "https://github.com/login/oauth/authorize?"+params.Encode()) 731 + +} 732 + + 733 + +// @Summary GitHub OAuth callback 734 + +// @Description Exchanges the code, finds or creates the user by primary verified email, sets the session and redirects back into the app 735 + +// @Tags auth 736 + +// @Router /auth/github/callback [get] 737 + +func githubCallback(c *gin.Context) { 738 + + session := sessions.Default(c) 739 + + expectedState, _ := session.Get(githubStateKey).(string) 740 + + redirect, _ := session.Get(githubRedirectKey).(string) 741 + + tzString, _ := session.Get(githubTzKey).(string) 742 + + eventsToLink, _ := session.Get(githubLinkKey).(string) 743 + + // The state is single-use: every exit path below saves the session with 744 + + // these keys removed. Each Save emits a Set-Cookie, so save exactly once. 745 + + session.Delete(githubStateKey) 746 + + session.Delete(githubRedirectKey) 747 + + session.Delete(githubTzKey) 748 + + session.Delete(githubLinkKey) 749 + + 750 + + fail := func(reason string, err error) { 751 + + logger.StdErr.Printf("GitHub sign-in failed: %s: %v", reason, err) 752 + + session.Save() 753 + + c.Redirect(http.StatusFound, "/sign-in?error=github") 754 + + } 755 + + 756 + + if expectedState == "" || c.Query("state") != expectedState { 757 + + fail("state mismatch", nil) 758 + + return 759 + + } 760 + + if c.Query("error") != "" { 761 + + fail("denied", fmt.Errorf("%s", c.Query("error"))) 762 + + return 763 + + } 764 + + 765 + + accessToken, err := githubExchangeCode(c.Query("code")) 766 + + if err != nil { 767 + + fail("code exchange", err) 768 + + return 769 + + } 770 + + 771 + + var ghUser struct { 772 + + Login string `json:"login"` 773 + + Name string `json:"name"` 774 + + AvatarUrl string `json:"avatar_url"` 775 + + } 776 + + if err := githubGet(accessToken, "/user", &ghUser); err != nil { 777 + + fail("fetch user", err) 778 + + return 779 + + } 780 + + 781 + + var ghEmails []struct { 782 + + Email string `json:"email"` 783 + + Primary bool `json:"primary"` 784 + + Verified bool `json:"verified"` 785 + + } 786 + + if err := githubGet(accessToken, "/user/emails", &ghEmails); err != nil { 787 + + fail("fetch emails", err) 788 + + return 789 + + } 790 + + email := "" 791 + + for _, e := range ghEmails { 792 + + if e.Verified && (e.Primary || email == "") { 793 + + email = e.Email 794 + + } 795 + + } 796 + + if email == "" { 797 + + fail("no verified email", nil) 798 + + return 799 + + } 800 + + email = utils.NormalizeEmail(email) 801 + + 802 + + var userId primitive.ObjectID 803 + + if existing := db.GetUserByEmail(email); existing != nil { 804 + + userId = existing.Id 805 + + } else { 806 + + firstName, lastName := ghUser.Login, "" 807 + + if name := strings.TrimSpace(ghUser.Name); name != "" { 808 + + firstName, lastName, _ = strings.Cut(name, " ") 809 + + } 810 + + timezoneOffset, _ := strconv.Atoi(tzString) 811 + + 812 + + res, err := db.UsersCollection.InsertOne(context.Background(), models.User{ 813 + + Email: email, 814 + + FirstName: firstName, 815 + + LastName: strings.TrimSpace(lastName), 816 + + Picture: ghUser.AvatarUrl, 817 + + TimezoneOffset: timezoneOffset, 818 + + TokenOrigin: models.WEB, 819 + + }) 820 + + if err != nil { 821 + + logger.StdErr.Panicln(err) 822 + + } 823 + + userId = res.InsertedID.(primitive.ObjectID) 824 + + } 825 + + 826 + + // Claim events this browser created while signed out (same as /auth/sign-in) 827 + + for _, eventIdString := range strings.Split(eventsToLink, ",") { 828 + + eventId, err := primitive.ObjectIDFromHex(eventIdString) 829 + + if err == nil { 830 + + db.EventsCollection.UpdateOne(context.Background(), bson.M{"_id": eventId, "ownerId": nil}, bson.M{"$set": bson.M{"ownerId": userId}}) 831 + + } 832 + + } 833 + + 834 + + session.Set("userId", userId.Hex()) 835 + + session.Save() 836 + + 837 + + c.Redirect(http.StatusFound, redirect) 838 + +} 839 + + 840 + +// safeRedirectPath only allows same-origin absolute paths, so the callback 841 + +// cannot be turned into an open redirect. 842 + +func safeRedirectPath(path string) string { 843 + + if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.HasPrefix(path, "/\\") { 844 + + return "/home" 845 + + } 846 + + return path 847 + +} 848 + + 849 + +func githubExchangeCode(code string) (string, error) { 850 + + form := url.Values{} 851 + + form.Set("client_id", os.Getenv("GITHUB_CLIENT_ID")) 852 + + form.Set("client_secret", os.Getenv("GITHUB_CLIENT_SECRET")) 853 + + form.Set("code", code) 854 + + 855 + + req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode())) 856 + + if err != nil { 857 + + return "", err 858 + + } 859 + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 860 + + req.Header.Set("Accept", "application/json") 861 + + 862 + + resp, err := githubHttpClient.Do(req) 863 + + if err != nil { 864 + + return "", err 865 + + } 866 + + defer resp.Body.Close() 867 + + 868 + + var body struct { 869 + + AccessToken string `json:"access_token"` 870 + + Error string `json:"error"` 871 + + } 872 + + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { 873 + + return "", err 874 + + } 875 + + if body.AccessToken == "" { 876 + + return "", fmt.Errorf("no access token (status %d, error %q)", resp.StatusCode, body.Error) 877 + + } 878 + + return body.AccessToken, nil 879 + +} 880 + + 881 + +func githubGet(accessToken string, path string, out interface{}) error { 882 + + req, err := http.NewRequest(http.MethodGet, "https://api.github.com"+path, nil) 883 + + if err != nil { 884 + + return err 885 + + } 886 + + req.Header.Set("Authorization", "Bearer "+accessToken) 887 + + req.Header.Set("Accept", "application/vnd.github+json") 888 + + 889 + + resp, err := githubHttpClient.Do(req) 890 + + if err != nil { 891 + + return err 892 + + } 893 + + defer resp.Body.Close() 894 + + if resp.StatusCode != http.StatusOK { 895 + + return fmt.Errorf("GET %s: status %d", path, resp.StatusCode) 896 + + } 897 + + return json.NewDecoder(resp.Body).Decode(out) 898 + +}
+56
pkgs/tincr/package.nix
··· 1 + { 2 + lib, 3 + rustPlatform, 4 + fetchFromGitHub, 5 + installShellFiles, 6 + pkg-config, 7 + openssl, 8 + }: 9 + 10 + rustPlatform.buildRustPackage { 11 + pname = "tincr"; 12 + version = "0-unstable-2026-09-14"; 13 + 14 + # Our fork's integration branch until Mic92/tincr#101 + #109 land: NAT-leaf dial 15 + # (#100), stale-loop-clock auth timeouts, tunnel-address gate, PMTU-decrease recovery. 16 + src = fetchFromGitHub { 17 + owner = "Lassulus"; 18 + repo = "tincr"; 19 + rev = "97b055f71ef80ea4b2d3e1e87b8819968ec00697"; 20 + hash = "sha256-sJ5sH0cP0+bEjFSspkP339jzwrSolZoYUVKopg8NDqc="; 21 + }; 22 + 23 + cargoHash = "sha256-RcyJ7NV3EH6GeIbapsQGJLBx8BkWbiFeeR4CB8k12ak="; 24 + 25 + # Just the deployable bin crates; --workspace would pull tinc-ffi's cc. 26 + cargoBuildFlags = [ 27 + "-p" 28 + "tincd" 29 + "-p" 30 + "tinc-tools" 31 + ]; 32 + 33 + # netns tests need bwrap+userns the build sandbox lacks. 34 + doCheck = false; 35 + 36 + # tinc-crypto's ChaPoly backend links openssl-sys against system openssl. 37 + nativeBuildInputs = [ 38 + installShellFiles 39 + pkg-config 40 + ]; 41 + buildInputs = [ openssl ]; 42 + 43 + postInstall = '' 44 + installManPage man/*.[0-9] 45 + ''; 46 + 47 + passthru.updateScript = ./update.sh; 48 + 49 + meta = { 50 + description = "Drop-in Rust rewrite of tinc 1.1 VPN (SPTPS-only, Ed25519)"; 51 + homepage = "https://github.com/Mic92/tincr"; 52 + license = lib.licenses.gpl2Plus; 53 + mainProgram = "tincd"; 54 + platforms = lib.platforms.linux ++ lib.platforms.darwin; 55 + }; 56 + }
+69
pkgs/tincr/update.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + # Tracks our fork's integration branch (Mic92/tincr#101 + #109); switch back to 5 + # Mic92/main once merged. 6 + owner=Lassulus 7 + repo=tincr 8 + branch=lassulus/fixes 9 + attr=tincr 10 + 11 + pkg="$(dirname "$(realpath "$0")")/package.nix" 12 + 13 + cd "$(dirname "$(realpath "$0")")" 14 + while [ ! -e flake.nix ] && [ "$PWD" != / ]; do cd ..; done 15 + system=$(nix eval --raw --impure --expr 'builtins.currentSystem') 16 + 17 + new_rev=$(curl -fsSL "https://api.github.com/repos/$owner/$repo/commits/$branch" | jq -r .sha) 18 + old_rev=$(grep -oP '^\s*rev = "\K[^"]+' "$pkg") 19 + if [ "$old_rev" = "$new_rev" ]; then 20 + echo "$attr: already up to date ($new_rev)" 21 + exit 0 22 + fi 23 + 24 + new_date=$(curl -fsSL "https://api.github.com/repos/$owner/$repo/commits/$new_rev" | jq -r .commit.author.date | cut -d T -f1) 25 + new_version="0-unstable-$new_date" 26 + 27 + # Bump rev + version, leave hashes for nix to complain about. 28 + # Blank both hashes too: with the old ones in place nix reuses the cached 29 + # FODs (old src + old vendor dir) and the build "succeeds" on stale code. 30 + sed -i \ 31 + -e "s|^\(\s*\)rev = \".*\"|\1rev = \"$new_rev\"|" \ 32 + -e "s|^\(\s*\)version = \".*\"|\1version = \"$new_version\"|" \ 33 + -e "s|^\(\s*\)hash = \".*\"|\1hash = \"\"|" \ 34 + -e "s|^\(\s*\)cargoHash = \".*\"|\1cargoHash = \"\"|" \ 35 + "$pkg" 36 + 37 + # Each FOD reports its own hash on first miss; build twice (src, then vendor). 38 + extract_got() { 39 + awk '/got:/{print $2; exit}' 40 + } 41 + update_hash() { 42 + local field=$1 new=$2 43 + sed -i "s|^\(\s*\)$field = \"[^\"]*\"|\1$field = \"$new\"|" "$pkg" 44 + } 45 + 46 + build_attr=".#packages.$system.$attr" 47 + 48 + # The probe build is *expected* to fail (hash mismatch); `|| true` keeps 49 + # pipefail/errexit from turning that into an empty `got`. awk must not 50 + # `exit` early or tee dies of SIGPIPE and takes the pipeline with it. 51 + probe_hash() { 52 + { nix build --no-link "$build_attr" 2>&1 || true; } | tee /dev/stderr \ 53 + | awk '/got:/ && !seen { print $2; seen = 1 }' || true 54 + } 55 + 56 + got=$(probe_hash) 57 + if [ -n "$got" ]; then 58 + update_hash hash "$got" 59 + echo "$attr: src hash → $got" 60 + fi 61 + 62 + got=$(probe_hash) 63 + if [ -n "$got" ]; then 64 + update_hash cargoHash "$got" 65 + echo "$attr: cargoHash → $got" 66 + fi 67 + 68 + echo "$attr: updated to $new_rev ($new_version)" 69 + nix build --no-link "$build_attr"
+67
pkgs/vosk/package.nix
··· 1 + { 2 + lib, 3 + python3Packages, 4 + fetchurl, 5 + stdenv, 6 + autoPatchelfHook, 7 + }: 8 + let 9 + version = "0.3.44"; 10 + 11 + # Platform-specific wheels 12 + wheels = { 13 + "x86_64-linux" = { 14 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.whl"; 15 + hash = "sha256-JlLvITxMeAfcQK8eeqV43Mz8TcQZKCKia8RalZG8v3Y="; 16 + }; 17 + "aarch64-linux" = { 18 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-manylinux2014_aarch64.whl"; 19 + hash = "sha256-mZW3+lQ0sTS0vQmJfiSQ28VhFcKtKUPAMGIeEMcDezQ="; 20 + }; 21 + "x86_64-darwin" = { 22 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-macosx_10_6_universal2.whl"; 23 + hash = "sha256-Ap0LPWpc/4dMV1uKWBSkzM+zdgjP9S6EbAKoxnqIKAE="; 24 + }; 25 + "aarch64-darwin" = { 26 + url = "https://files.pythonhosted.org/packages/py3/v/vosk/vosk-${version}-py3-none-macosx_10_6_universal2.whl"; 27 + hash = "sha256-Ap0LPWpc/4dMV1uKWBSkzM+zdgjP9S6EbAKoxnqIKAE="; 28 + }; 29 + }; 30 + 31 + wheel = 32 + wheels.${stdenv.hostPlatform.system} 33 + or (throw "Unsupported platform: ${stdenv.hostPlatform.system}"); 34 + in 35 + python3Packages.buildPythonPackage { 36 + pname = "vosk"; 37 + inherit version; 38 + format = "wheel"; 39 + 40 + src = fetchurl { 41 + inherit (wheel) url hash; 42 + }; 43 + 44 + nativeBuildInputs = [ autoPatchelfHook ]; 45 + 46 + buildInputs = [ stdenv.cc.cc.lib ]; 47 + 48 + dependencies = with python3Packages; [ 49 + cffi 50 + requests 51 + tqdm 52 + srt 53 + websockets 54 + ]; 55 + 56 + # Skip tests as they require audio files and models 57 + doCheck = false; 58 + 59 + pythonImportsCheck = [ "vosk" ]; 60 + 61 + meta = { 62 + description = "Offline speech recognition API based on Kaldi and Vosk"; 63 + homepage = "https://alphacephei.com/vosk/"; 64 + license = lib.licenses.asl20; 65 + platforms = lib.attrNames wheels; 66 + }; 67 + }
+35
pkgs/zmk-studio/package.nix
··· 1 + { 2 + lib, 3 + appimageTools, 4 + fetchurl, 5 + }: 6 + 7 + let 8 + pname = "zmk-studio"; 9 + version = "0.3.1"; 10 + 11 + src = fetchurl { 12 + url = "https://github.com/zmkfirmware/zmk-studio/releases/download/v${version}/ZMK.Studio_${version}_amd64.AppImage"; 13 + hash = "sha256-kDt3AxPV1901WH3n6+mmylLwnvRrAuHSlFlRK9qEoOs="; 14 + }; 15 + 16 + appimageContents = appimageTools.extractType2 { inherit pname version src; }; 17 + in 18 + appimageTools.wrapType2 { 19 + inherit pname version src; 20 + 21 + extraInstallCommands = '' 22 + install -Dm444 "${appimageContents}/ZMK Studio.desktop" $out/share/applications/zmk-studio.desktop 23 + install -Dm444 ${appimageContents}/zmk-studio.png $out/share/icons/hicolor/128x128/apps/zmk-studio.png 24 + ''; 25 + 26 + meta = { 27 + description = "Runtime keymap editor for ZMK keyboards"; 28 + homepage = "https://zmk.studio/"; 29 + downloadPage = "https://github.com/zmkfirmware/zmk-studio/releases"; 30 + license = lib.licenses.mit; 31 + maintainers = [ ]; 32 + platforms = [ "x86_64-linux" ]; 33 + mainProgram = "zmk-studio"; 34 + }; 35 + }
+1 -1
tools/mutt/flake-module.nix
··· 13 13 notmuchConfig = self.packages.${system}.notmuch.passthru.configuration.configFile.path; 14 14 15 15 # nixpkgs's autotools elinks is broken against gettext 0.23.1. 16 - # Use our local meson build (5pkgs/elinks) until upstream PR 16 + # Use our local meson build (pkgs/elinks) until upstream PR 17 17 # #515347 lands. 18 18 elinks = self.packages.${system}.elinks; 19 19
-798
5pkgs/archiver-bot/archiver_bot/__init__.py
··· 1 - import asyncio 2 - import json 3 - import logging 4 - import os 5 - import re 6 - import sys 7 - from pathlib import Path 8 - 9 - from urllib.parse import quote 10 - 11 - import aiohttp 12 - from aiohttp import web 13 - from nio import ( 14 - AsyncClient, 15 - AsyncClientConfig, 16 - InviteMemberEvent, 17 - LoginResponse, 18 - MatrixRoom, 19 - MegolmEvent, 20 - RoomMessageText, 21 - ) 22 - from nio.api import Api 23 - from nio.responses import JoinError, JoinResponse 24 - 25 - logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 26 - log = logging.getLogger("archiver-bot") 27 - 28 - IMDB_RE = re.compile(r"https?://(?:www\.)?imdb\.com/(?:[a-z]{2}/)?title/(tt\d+)") 29 - TMDB_MOVIE_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/movie/(\d+)") 30 - TMDB_TV_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/tv/(\d+)") 31 - 32 - JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "http://localhost:5055") 33 - JELLYSEERR_API_KEY = os.environ["JELLYSEERR_API_KEY"] 34 - RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") 35 - SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") 36 - MATRIX_HOMESERVER = os.environ["MATRIX_HOMESERVER"] 37 - MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] 38 - MATRIX_USER_ID = os.environ.get("MATRIX_USER_ID", "@archiver:lassul.us") 39 - MATRIX_DEVICE_ID = os.environ.get("MATRIX_DEVICE_ID", "ARCHIVER") 40 - STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot") 41 - WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099")) 42 - FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us") 43 - FLIX_URL = os.environ.get("FLIX_URL", "https://flix.lassul.us") 44 - JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://localhost:8096") 45 - JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") 46 - IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.lassul.us") 47 - MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/") 48 - IPFS_BIN = os.environ.get("IPFS_BIN", "ipfs") 49 - 50 - # Persistent tracking of requested media -> rooms 51 - TRACKING_FILE = Path(STORE_PATH) / "tracked_requests.json" 52 - 53 - 54 - def load_tracked() -> dict: 55 - """Load tracked requests. Schema: {key: [{"room": id, "event": id|None}, ...]}. 56 - Legacy entries (bare room_id strings) are normalized to event=None.""" 57 - try: 58 - raw = json.loads(TRACKING_FILE.read_text()) 59 - except Exception: 60 - return {} 61 - out = {} 62 - for key, entries in raw.items(): 63 - normalized = [] 64 - for e in entries: 65 - if isinstance(e, str): 66 - normalized.append({"room": e, "event": None}) 67 - elif isinstance(e, dict) and e.get("room"): 68 - normalized.append({"room": e["room"], "event": e.get("event")}) 69 - out[key] = normalized 70 - return out 71 - 72 - 73 - def save_tracked(tracked: dict): 74 - """Save tracked requests to disk.""" 75 - TRACKING_FILE.write_text(json.dumps(tracked)) 76 - 77 - 78 - def track_request(media_type: str, tmdb_id: int, room_id: str, event_id: str | None = None): 79 - """Track that a room requested this media; event_id is the originating 80 - message so download notifications can thread back into that conversation.""" 81 - tracked = load_tracked() 82 - key = f"{media_type}:{tmdb_id}" 83 - entries = tracked.get(key, []) 84 - for entry in entries: 85 - if entry["room"] == room_id: 86 - if event_id is not None: 87 - entry["event"] = event_id 88 - break 89 - else: 90 - entries.append({"room": room_id, "event": event_id}) 91 - tracked[key] = entries 92 - save_tracked(tracked) 93 - 94 - 95 - def pop_tracked(media_type: str, tmdb_id: int) -> list[dict]: 96 - """Get and remove tracked entries for this media.""" 97 - tracked = load_tracked() 98 - key = f"{media_type}:{tmdb_id}" 99 - entries = tracked.pop(key, []) 100 - save_tracked(tracked) 101 - return entries 102 - 103 - 104 - def make_flax_link(path: str) -> str: 105 - """Turn a local path like /var/download/movies/Title (Year)/file.mp4 into a flax.lassul.us URL.""" 106 - from urllib.parse import quote 107 - relative = path 108 - if relative.startswith(MEDIA_PATH_PREFIX): 109 - relative = relative[len(MEDIA_PATH_PREFIX):] 110 - return f"{FLAX_URL}/{quote(relative)}" 111 - 112 - 113 - def find_main_video(path: str) -> str | None: 114 - """Find the main video file in a path. If path is a file, return it. If directory, find largest video.""" 115 - if os.path.isfile(path): 116 - return path 117 - if os.path.isdir(path): 118 - video_exts = {".mkv", ".mp4", ".avi", ".m4v", ".ts", ".wmv"} 119 - best = None 120 - best_size = 0 121 - for root, _dirs, files in os.walk(path): 122 - for f in files: 123 - ext = os.path.splitext(f)[1].lower() 124 - if ext in video_exts: 125 - full = os.path.join(root, f) 126 - size = os.path.getsize(full) 127 - if size > best_size: 128 - best = full 129 - best_size = size 130 - return best 131 - return None 132 - 133 - 134 - async def compute_cid(path: str) -> str | None: 135 - """Compute the IPFS CID for a file using ipfs add --only-hash --raw-leaves.""" 136 - video = find_main_video(path) 137 - if not video: 138 - log.warning(f"No video file found for {path}") 139 - return None 140 - import tempfile 141 - import shutil 142 - ipfs_path = tempfile.mkdtemp(prefix="ipfs-hash-") 143 - try: 144 - env = {**os.environ, "IPFS_PATH": ipfs_path} 145 - init_proc = await asyncio.create_subprocess_exec( 146 - IPFS_BIN, "init", 147 - stdout=asyncio.subprocess.PIPE, 148 - stderr=asyncio.subprocess.PIPE, 149 - env=env, 150 - ) 151 - await init_proc.communicate() 152 - proc = await asyncio.create_subprocess_exec( 153 - IPFS_BIN, "add", "--only-hash", "--raw-leaves", "--quieter", video, 154 - stdout=asyncio.subprocess.PIPE, 155 - stderr=asyncio.subprocess.PIPE, 156 - env=env, 157 - ) 158 - try: 159 - stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300) 160 - except asyncio.TimeoutError: 161 - proc.kill() 162 - await proc.communicate() 163 - log.warning(f"ipfs add --only-hash timed out for {video}") 164 - return None 165 - if proc.returncode == 0: 166 - cid = stdout.decode().strip() 167 - if cid: 168 - return cid 169 - else: 170 - log.warning(f"ipfs add --only-hash failed for {video}: {stderr.decode().strip()}") 171 - except Exception as e: 172 - log.warning(f"Failed to compute CID for {video}: {e}") 173 - finally: 174 - shutil.rmtree(ipfs_path, ignore_errors=True) 175 - return None 176 - 177 - 178 - async def trigger_jellyfin_scan(session: aiohttp.ClientSession): 179 - """Trigger a Jellyfin library scan and Jellyseerr sync.""" 180 - try: 181 - async with session.post( 182 - f"{JELLYFIN_URL}/Library/Refresh", 183 - headers={"X-Emby-Token": JELLYFIN_API_KEY}, 184 - ) as resp: 185 - log.info(f"Triggered Jellyfin library scan: {resp.status}") 186 - # also trigger Jellyseerr sync so it picks up the new jellyfinMediaId 187 - await jellyseerr_request(session, "/settings/jellyfin/sync", method="POST") 188 - log.info("Triggered Jellyseerr sync") 189 - except Exception as e: 190 - log.warning(f"Failed to trigger library scan: {e}") 191 - 192 - 193 - async def lookup_jellyfin_id(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 194 - """Look up Jellyfin item ID via Jellyseerr's mediaInfo or Jellyfin API.""" 195 - # Try Jellyseerr first — it tracks the Jellyfin media ID 196 - # Try Jellyseerr first 197 - try: 198 - endpoint = f"/movie/{tmdb_id}" if media_type == "movie" else f"/tv/{tmdb_id}" 199 - details = await jellyseerr_request(session, endpoint) 200 - media_info = details.get("mediaInfo") 201 - if media_info: 202 - jf_id = media_info.get("jellyfinMediaId") 203 - if jf_id: 204 - log.info(f"Jellyfin ID from Jellyseerr for {media_type}:{tmdb_id} = {jf_id}") 205 - return jf_id 206 - except Exception as e: 207 - log.warning(f"Jellyseerr Jellyfin ID lookup failed for {media_type}:{tmdb_id}: {e}") 208 - 209 - # Fallback: query Jellyfin directly, searching by provider ID 210 - if JELLYFIN_API_KEY: 211 - try: 212 - item_type = "Movie" if media_type == "movie" else "Series" 213 - async with session.get( 214 - f"{JELLYFIN_URL}/Items", 215 - params={ 216 - "IncludeItemTypes": item_type, 217 - "Recursive": "true", 218 - "Fields": "ProviderIds", 219 - }, 220 - headers={"X-Emby-Token": JELLYFIN_API_KEY}, 221 - ) as resp: 222 - data = await resp.json() 223 - for item in data.get("Items", []): 224 - provider_ids = item.get("ProviderIds", {}) 225 - if str(provider_ids.get("Tmdb")) == str(tmdb_id): 226 - jf_id = item.get("Id") 227 - log.info(f"Jellyfin ID from Jellyfin API for {media_type}:{tmdb_id} = {jf_id}") 228 - return jf_id 229 - except Exception as e: 230 - log.warning(f"Jellyfin API lookup failed for {media_type}:{tmdb_id}: {e}") 231 - 232 - log.info(f"No Jellyfin ID found for {media_type}:{tmdb_id}") 233 - return None 234 - 235 - 236 - async def make_media_links(path: str, is_dir: bool = False, jellyfin_id: str | None = None) -> tuple[str, str]: 237 - """Generate plain and HTML text with all available links for a media path.""" 238 - from urllib.parse import quote 239 - 240 - lines_plain = [] 241 - lines_html = [] 242 - 243 - # Flax link (direct file download) 244 - flax = make_flax_link(path) 245 - lines_plain.append(f"📁 Download: {flax}") 246 - lines_html.append(f'📁 Download: <a href="{flax}">{flax}</a>') 247 - 248 - # Jellyfin link (streaming) 249 - if jellyfin_id: 250 - flix_link = f"{FLIX_URL}/web/#/details?id={jellyfin_id}" 251 - else: 252 - flix_link = FLIX_URL 253 - lines_plain.append(f"🎬 Stream: {flix_link}") 254 - lines_html.append(f'🎬 Stream: <a href="{flix_link}">{flix_link}</a>') 255 - 256 - # IPFS link (compute CID for the file, retry up to 3 times) 257 - cid = None 258 - for attempt in range(3): 259 - cid = await compute_cid(path) 260 - if cid: 261 - break 262 - log.warning(f"IPFS hash attempt {attempt + 1}/3 failed for {path}") 263 - await asyncio.sleep(2) 264 - 265 - if cid: 266 - ipfs_link = f"{IPFS_GATEWAY_URL}/ipfs/{cid}" 267 - lines_plain.append(f"🌐 IPFS: {ipfs_link}") 268 - lines_html.append(f'🌐 IPFS: <a href="{ipfs_link}">{ipfs_link}</a>') 269 - lines_plain.append(f" ipfs get {cid}") 270 - lines_html.append(f" <code>ipfs get {cid}</code>") 271 - else: 272 - log.error(f"IPFS hash failed after 3 attempts for {path}, not posting message") 273 - return None 274 - 275 - return ("\n".join(lines_plain), "<br>".join(lines_html)) 276 - 277 - 278 - async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict: 279 - """Make an authenticated request to Jellyseerr.""" 280 - headers = { 281 - "X-Api-Key": JELLYSEERR_API_KEY, 282 - "Content-Type": "application/json", 283 - "Accept": "application/json", 284 - } 285 - method = kwargs.pop("method", "GET") 286 - url = f"{JELLYSEERR_URL}/api/v1{path}" 287 - async with session.request(method, url, headers=headers, **kwargs) as resp: 288 - return await resp.json() 289 - 290 - 291 - async def find_by_imdb(session: aiohttp.ClientSession, imdb_id: str) -> dict | None: 292 - """Look up a movie or TV show by IMDb ID via Radarr/Sonarr, returning Jellyseerr-compatible data.""" 293 - 294 - # Try Radarr (movies) first 295 - try: 296 - async with session.get( 297 - f"http://localhost:7878/api/v3/movie/lookup?term=imdb:{imdb_id}", 298 - headers={"X-Api-Key": RADARR_API_KEY}, 299 - ) as resp: 300 - results = await resp.json() 301 - if results and len(results) > 0: 302 - tmdb_id = results[0].get("tmdbId") 303 - title = results[0].get("title", "Unknown") 304 - year = str(results[0].get("year", "")) 305 - if tmdb_id: 306 - log.info(f"Radarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 307 - return { 308 - "id": tmdb_id, 309 - "mediaType": "movie", 310 - "title": title, 311 - "releaseDate": f"{year}-01-01" if year else "", 312 - } 313 - except Exception as e: 314 - log.warning(f"Radarr lookup failed: {e}") 315 - 316 - # Try Sonarr (TV shows) 317 - try: 318 - async with session.get( 319 - f"http://localhost:8989/api/v3/series/lookup?term=imdb:{imdb_id}", 320 - headers={"X-Api-Key": SONARR_API_KEY}, 321 - ) as resp: 322 - results = await resp.json() 323 - if results and len(results) > 0: 324 - tmdb_id = results[0].get("tmdbId", 0) 325 - title = results[0].get("title", "Unknown") 326 - year = str(results[0].get("year", "")) 327 - if tmdb_id: 328 - log.info(f"Sonarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 329 - return { 330 - "id": tmdb_id, 331 - "mediaType": "tv", 332 - "name": title, 333 - "firstAirDate": f"{year}-01-01" if year else "", 334 - } 335 - except Exception as e: 336 - log.warning(f"Sonarr lookup failed: {e}") 337 - 338 - return None 339 - 340 - 341 - async def lookup_media_path(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 342 - """Look up the path for a movie file or show folder from Radarr/Sonarr by TMDB ID.""" 343 - try: 344 - if media_type == "movie": 345 - async with session.get( 346 - f"http://localhost:7878/api/v3/movie?tmdbId={tmdb_id}", 347 - headers={"X-Api-Key": RADARR_API_KEY}, 348 - ) as resp: 349 - results = await resp.json() 350 - if results and len(results) > 0: 351 - movie = results[0] 352 - if not movie.get("hasFile"): 353 - return None 354 - movie_file = movie.get("movieFile", {}) 355 - if movie_file and movie_file.get("path"): 356 - return movie_file["path"] 357 - return None 358 - else: 359 - async with session.get( 360 - f"http://localhost:8989/api/v3/series?tvdbId={tmdb_id}", 361 - headers={"X-Api-Key": SONARR_API_KEY}, 362 - ) as resp: 363 - results = await resp.json() 364 - if results and len(results) > 0: 365 - return results[0].get("path") 366 - except Exception as e: 367 - log.warning(f"Media path lookup failed for {media_type}:{tmdb_id}: {e}") 368 - return None 369 - 370 - 371 - async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str, event_id: str | None = None) -> tuple[str, str]: 372 - """Request a movie or TV show via Jellyseerr. Returns (plain, html) status message.""" 373 - media_type = media["mediaType"] 374 - media_id = media["id"] 375 - title = media.get("title") or media.get("name") or "Unknown" 376 - year = (media.get("releaseDate") or media.get("firstAirDate") or "")[:4] 377 - display = f"{title} ({year})" if year else title 378 - 379 - # Check if already available or requested 380 - existing = media.get("mediaInfo") 381 - if existing: 382 - status = existing.get("status") 383 - # status 5 = available, 4 = partially available, 3 = processing, 2 = pending 384 - if status == 5: 385 - path = await lookup_media_path(session, media_type, media_id) 386 - if path: 387 - is_dir = media_type == "tv" 388 - jf_id = await lookup_jellyfin_id(session, media_type, media_id) 389 - links = await make_media_links(path, is_dir=is_dir, jellyfin_id=jf_id) 390 - if not links: 391 - log.error(f"IPFS hash failed for {display}, not posting"); return None 392 - links_plain, links_html = links 393 - return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 394 - return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 395 - elif status in (2, 3, 4): 396 - track_request(media_type, media_id, room_id, event_id) 397 - return (f"⏳ {display} has already been requested and is being processed.", f"⏳ <b>{display}</b> has already been requested and is being processed.") 398 - 399 - payload = {"mediaType": media_type, "mediaId": media_id} 400 - if media_type == "tv": 401 - payload["seasons"] = "all" 402 - 403 - # Check if Radarr/Sonarr already has the file before requesting 404 - existing_path = await lookup_media_path(session, media_type, media_id) 405 - if existing_path: 406 - is_dir = media_type == "tv" 407 - jf_id = await lookup_jellyfin_id(session, media_type, media_id) 408 - links = await make_media_links(existing_path, is_dir=is_dir, jellyfin_id=jf_id) 409 - # Still send the request to Jellyseerr so it's tracked there too 410 - await jellyseerr_request(session, "/request", method="POST", json=payload) 411 - if not links: 412 - log.error(f"IPFS hash failed for {display}, not posting"); return None 413 - links_plain, links_html = links 414 - return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 415 - 416 - result = await jellyseerr_request(session, "/request", method="POST", json=payload) 417 - 418 - if "id" in result: 419 - track_request(media_type, media_id, room_id, event_id) 420 - return (f"🎬 Successfully requested {display}! I'll notify you when it's downloaded.", f"🎬 Successfully requested <b>{display}</b>! I'll notify you when it's downloaded.") 421 - else: 422 - error = result.get("message", json.dumps(result)) 423 - return (f"❌ Failed to request {display}: {error}", f"❌ Failed to request <b>{display}</b>: {error}") 424 - 425 - 426 - async def trust_room_devices(client: AsyncClient, room_id: str): 427 - """Trust all devices of all members in a room.""" 428 - if not client.olm: 429 - return 430 - room = client.rooms.get(room_id) 431 - if not room: 432 - return 433 - for user_id in room.users: 434 - for device in client.device_store.active_user_devices(user_id): 435 - if not client.olm.is_device_verified(device): 436 - client.verify_device(device) 437 - log.info(f"Verified device {device.device_id} of {user_id}") 438 - 439 - 440 - async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str, thread_root: str | None = None): 441 - """Send a notice message to a room. If thread_root is given, the message 442 - becomes a reply within that thread (with the in_reply_to fallback so 443 - non-threaded clients still render a quote).""" 444 - await trust_room_devices(client, room_id) 445 - content = { 446 - "msgtype": "m.notice", 447 - "body": plain, 448 - "format": "org.matrix.custom.html", 449 - "formatted_body": html, 450 - } 451 - if thread_root: 452 - content["m.relates_to"] = { 453 - "rel_type": "m.thread", 454 - "event_id": thread_root, 455 - "is_falling_back": True, 456 - "m.in_reply_to": {"event_id": thread_root}, 457 - } 458 - await client.room_send(room_id, "m.room.message", content) 459 - 460 - 461 - def thread_root_for(event) -> str: 462 - """If event is already part of a thread, return that thread's root; 463 - otherwise return event's own id (start a new thread on it).""" 464 - rel = (getattr(event, "source", None) or {}).get("content", {}).get("m.relates_to") or {} 465 - if rel.get("rel_type") == "m.thread" and rel.get("event_id"): 466 - return rel["event_id"] 467 - return event.event_id 468 - 469 - 470 - async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession): 471 - """Handle messages in rooms, looking for IMDb/TMDB links.""" 472 - # Ignore our own messages 473 - if event.sender == client.user_id: 474 - return 475 - 476 - body = event.body 477 - log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}") 478 - thread_root = thread_root_for(event) 479 - 480 - # Help command 481 - if body.strip().lower() in ("help", "!help"): 482 - plain = ( 483 - "Archiver Bot\n\n" 484 - "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.\n" 485 - "I'll notify you when the download is complete.\n\n" 486 - "Examples:\n" 487 - " https://www.imdb.com/title/tt0133093/\n" 488 - " https://www.themoviedb.org/movie/603\n" 489 - " https://www.themoviedb.org/tv/1399" 490 - ) 491 - html = ( 492 - "<b>Archiver Bot</b><br><br>" 493 - "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.<br>" 494 - "I'll notify you when the download is complete.<br><br>" 495 - "Examples:<br>" 496 - "<code>https://www.imdb.com/title/tt0133093/</code><br>" 497 - "<code>https://www.themoviedb.org/movie/603</code><br>" 498 - "<code>https://www.themoviedb.org/tv/1399</code>" 499 - ) 500 - await send_notice(client, room.room_id, plain, html, thread_root=thread_root) 501 - return 502 - 503 - # Collect all media references from the message 504 - results = [] 505 - 506 - for imdb_id in set(IMDB_RE.findall(body)): 507 - log.info(f"Found IMDb link {imdb_id} in {room.display_name} from {event.sender}") 508 - media = await find_by_imdb(http, imdb_id) 509 - if media is None: 510 - results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for <code>{imdb_id}</code>.")) 511 - else: 512 - results.append(await request_media(http, media, room.room_id, thread_root)) 513 - 514 - for tmdb_id in set(TMDB_MOVIE_RE.findall(body)): 515 - log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}") 516 - media = {"id": int(tmdb_id), "mediaType": "movie"} 517 - try: 518 - details = await jellyseerr_request(http, f"/movie/{tmdb_id}") 519 - media["title"] = details.get("title", "Unknown") 520 - media["releaseDate"] = details.get("releaseDate", "") 521 - media["mediaInfo"] = details.get("mediaInfo") 522 - except Exception as e: 523 - log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}") 524 - results.append(await request_media(http, media, room.room_id, thread_root)) 525 - 526 - for tmdb_id in set(TMDB_TV_RE.findall(body)): 527 - log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}") 528 - media = {"id": int(tmdb_id), "mediaType": "tv"} 529 - try: 530 - details = await jellyseerr_request(http, f"/tv/{tmdb_id}") 531 - media["name"] = details.get("name", "Unknown") 532 - media["firstAirDate"] = details.get("firstAirDate", "") 533 - media["mediaInfo"] = details.get("mediaInfo") 534 - except Exception as e: 535 - log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}") 536 - results.append(await request_media(http, media, room.room_id, thread_root)) 537 - 538 - for result in results: 539 - if result is not None: 540 - plain, html = result 541 - await send_notice(client, room.room_id, plain, html, thread_root=thread_root) 542 - 543 - 544 - async def on_megolm(client: AsyncClient, room: MatrixRoom, event: MegolmEvent): 545 - """Handle messages we couldn't decrypt.""" 546 - log.warning( 547 - f"Failed to decrypt message from {event.sender} in {room.display_name} " 548 - f"(session {event.session_id})" 549 - ) 550 - # Request the keys we're missing 551 - if client.olm: 552 - try: 553 - resp = await client.request_room_key(event) 554 - log.info(f"Requested room key for session {event.session_id}: {resp}") 555 - except Exception as e: 556 - log.warning(f"Failed to request room key: {e}") 557 - await send_notice( 558 - client, 559 - room.room_id, 560 - "🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.", 561 - "🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.", 562 - thread_root=event.event_id, 563 - ) 564 - 565 - 566 - def _server_name(matrix_id: str | None) -> str | None: 567 - if matrix_id and ":" in matrix_id: 568 - return matrix_id.split(":", 1)[1] 569 - return None 570 - 571 - 572 - async def try_join(client: AsyncClient, room_id: str, via: str | None = None, attempts: int = 6) -> bool: 573 - # client.join doesn't expose server_name; pass it through to help our 574 - # homeserver resolve the room over federation. Retry with backoff because 575 - # invites delivered via sync can briefly race against the server's own 576 - # room-state view (M_FORBIDDEN even though the invite just arrived). 577 - method, path = Api.join(client.access_token, room_id) 578 - if via: 579 - path += "&server_name=" + quote(via) 580 - for attempt in range(1, attempts + 1): 581 - result = await client._send(JoinResponse, method, path) 582 - if not isinstance(result, JoinError): 583 - log.info(f"Joined {room_id} on attempt {attempt}") 584 - return True 585 - log.warning(f"Join attempt {attempt}/{attempts} for {room_id} failed: {result}") 586 - if attempt < attempts: 587 - await asyncio.sleep(2 ** (attempt - 1)) 588 - log.error(f"Gave up joining {room_id} after {attempts} attempts") 589 - return False 590 - 591 - 592 - async def fetch_pending_invites(client: AsyncClient, http: aiohttp.ClientSession) -> list[str]: 593 - # nio's incremental sync uses the persisted next_batch and synapse will 594 - # not re-deliver invites that were already seen in a prior session, so 595 - # client.invited_rooms misses anything the bot saw and failed to join 596 - # before a restart. Ask the server explicitly with a fresh /sync. 597 - url = f"{client.homeserver}/_matrix/client/v3/sync?timeout=0" 598 - headers = {"Authorization": f"Bearer {client.access_token}"} 599 - try: 600 - async with http.get(url, headers=headers) as resp: 601 - data = await resp.json() 602 - return list(data.get("rooms", {}).get("invite", {}).keys()) 603 - except Exception as e: 604 - log.warning(f"Failed to fetch pending invites: {e}") 605 - return [] 606 - 607 - 608 - async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent): 609 - """Auto-join rooms when invited.""" 610 - log.info(f"Invite event in {room.room_id}: state_key={event.state_key}, sender={event.sender}, membership={event.membership}, our_id={client.user_id}") 611 - if event.state_key != client.user_id: 612 - return 613 - log.info(f"Joining {room.room_id} (invited by {event.sender})...") 614 - await try_join(client, room.room_id, via=_server_name(event.sender)) 615 - 616 - 617 - async def handle_radarr_webhook(request): 618 - """Handle Radarr webhook for download completion.""" 619 - try: 620 - data = await request.json() 621 - event_type = data.get("eventType", "") 622 - log.info(f"Radarr webhook: {event_type}") 623 - 624 - if event_type in ("Download", "MovieFileDelete"): 625 - movie = data.get("movie", {}) 626 - tmdb_id = movie.get("tmdbId") 627 - title = movie.get("title", "Unknown") 628 - year = movie.get("year", "") 629 - display = f"{title} ({year})" if year else title 630 - # Prefer the actual movie file path, fall back to folder 631 - movie_file = data.get("movieFile", {}) 632 - file_path = movie_file.get("path") or movie.get("folderPath", "") 633 - 634 - if event_type == "Download" and tmdb_id: 635 - entries = pop_tracked("movie", tmdb_id) 636 - if entries: 637 - client = request.app["matrix_client"] 638 - http = request.app["http_session"] 639 - # trigger library scan so Jellyfin picks up the new file 640 - await trigger_jellyfin_scan(http) 641 - await asyncio.sleep(10) 642 - jf_id = await lookup_jellyfin_id(http, "movie", tmdb_id) 643 - links = await make_media_links(file_path, jellyfin_id=jf_id) 644 - if not links: 645 - log.error(f"IPFS hash failed for {display}, not notifying") 646 - return web.Response(text="ok") 647 - links_plain, links_html = links 648 - plain = f"✅ {display} has been downloaded and is now available!\n{links_plain}" 649 - html = f"✅ <b>{display}</b> has been downloaded and is now available!<br>{links_html}" 650 - for entry in entries: 651 - room_id = entry["room"] 652 - try: 653 - await send_notice(client, room_id, plain, html, thread_root=entry.get("event")) 654 - except Exception as e: 655 - log.warning(f"Failed to notify room {room_id}: {e}") 656 - except Exception as e: 657 - log.exception(f"Error handling Radarr webhook: {e}") 658 - 659 - return web.Response(text="ok") 660 - 661 - 662 - async def handle_sonarr_webhook(request): 663 - """Handle Sonarr webhook for download completion.""" 664 - try: 665 - data = await request.json() 666 - event_type = data.get("eventType", "") 667 - log.info(f"Sonarr webhook: {event_type}") 668 - 669 - if event_type in ("Download", "EpisodeFileDelete"): 670 - series = data.get("series", {}) 671 - tmdb_id = series.get("tmdbId") 672 - title = series.get("title", "Unknown") 673 - year = series.get("year", "") 674 - display = f"{title} ({year})" if year else title 675 - 676 - episodes = data.get("episodes", []) 677 - ep_info = "" 678 - if episodes: 679 - ep_nums = [f"S{ep.get('seasonNumber', 0):02d}E{ep.get('episodeNumber', 0):02d}" for ep in episodes] 680 - ep_info = f" ({', '.join(ep_nums)})" 681 - 682 - if event_type == "Download" and tmdb_id: 683 - entries = pop_tracked("tv", tmdb_id) 684 - if entries: 685 - client = request.app["matrix_client"] 686 - http = request.app["http_session"] 687 - folder_path = series.get("path", "") 688 - # trigger library scan so Jellyfin picks up the new file 689 - await trigger_jellyfin_scan(http) 690 - await asyncio.sleep(10) 691 - jf_id = await lookup_jellyfin_id(http, "tv", tmdb_id) 692 - links = await make_media_links(folder_path, is_dir=True, jellyfin_id=jf_id) 693 - if not links: 694 - log.error(f"IPFS hash failed for {display}, not notifying") 695 - return web.Response(text="ok") 696 - links_plain, links_html = links 697 - plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{links_plain}" 698 - html = f"✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br>{links_html}" 699 - for entry in entries: 700 - room_id = entry["room"] 701 - try: 702 - await send_notice(client, room_id, plain, html, thread_root=entry.get("event")) 703 - except Exception as e: 704 - log.warning(f"Failed to notify room {room_id}: {e}") 705 - except Exception as e: 706 - log.exception(f"Error handling Sonarr webhook: {e}") 707 - 708 - return web.Response(text="ok") 709 - 710 - 711 - async def run(): 712 - """Main bot loop.""" 713 - client_config = AsyncClientConfig( 714 - store_sync_tokens=True, 715 - encryption_enabled=True, 716 - ) 717 - client = AsyncClient( 718 - MATRIX_HOMESERVER, 719 - MATRIX_USER_ID, 720 - device_id=MATRIX_DEVICE_ID, 721 - store_path=STORE_PATH, 722 - config=client_config, 723 - ) 724 - client.access_token = MATRIX_ACCESS_TOKEN 725 - client.user_id = MATRIX_USER_ID 726 - client.load_store() 727 - log.info(f"Crypto store loaded, olm={client.olm is not None}") 728 - 729 - http = aiohttp.ClientSession() 730 - 731 - # Do an initial sync BEFORE registering callbacks to skip old messages 732 - log.info("Performing initial sync...") 733 - resp = await client.sync(timeout=10000, full_state=True) 734 - if hasattr(resp, "next_batch"): 735 - log.info(f"Initial sync complete, next_batch: {resp.next_batch}") 736 - else: 737 - log.error(f"Initial sync failed: {resp}") 738 - await http.close() 739 - await client.close() 740 - sys.exit(1) 741 - 742 - # Join any rooms we have a pending invite for (server-side truth; see 743 - # fetch_pending_invites for why we can't rely on client.invited_rooms). 744 - pending = await fetch_pending_invites(client, http) 745 - for room_id in pending: 746 - log.info(f"Joining pending invite for {room_id}...") 747 - await try_join(client, room_id, via=_server_name(room_id)) 748 - 749 - # E2EE setup 750 - if client.olm: 751 - try: 752 - log.info("Uploading device keys...") 753 - key_upload = await client.keys_upload() 754 - log.info(f"Key upload result: {key_upload}") 755 - except Exception as e: 756 - log.info(f"Key upload skipped: {e}") 757 - 758 - # Trust all known device keys so we can decrypt messages 759 - for user_id in client.device_store.users: 760 - for device in client.device_store.active_user_devices(user_id): 761 - if not client.olm.is_device_verified(device): 762 - client.verify_device(device) 763 - log.info(f"Verified device {device.device_id} of {user_id}") 764 - else: 765 - log.warning("Olm not available after sync — E2EE will not work") 766 - 767 - # Register event callbacks only after initial sync 768 - client.add_event_callback(lambda room, event: on_message(client, room, event, http), RoomMessageText) 769 - client.add_event_callback(lambda room, event: on_invite(client, room, event), InviteMemberEvent) 770 - client.add_event_callback(lambda room, event: on_megolm(client, room, event), MegolmEvent) 771 - 772 - # Start webhook server for Radarr/Sonarr notifications 773 - app = web.Application() 774 - app["matrix_client"] = client 775 - app["http_session"] = http 776 - app.router.add_post("/webhook/radarr", handle_radarr_webhook) 777 - app.router.add_post("/webhook/sonarr", handle_sonarr_webhook) 778 - runner = web.AppRunner(app) 779 - await runner.setup() 780 - site = web.TCPSite(runner, "127.0.0.1", WEBHOOK_PORT) 781 - await site.start() 782 - log.info(f"Webhook server listening on 127.0.0.1:{WEBHOOK_PORT}") 783 - 784 - log.info(f"Bot {MATRIX_USER_ID} is running!") 785 - 786 - try: 787 - await client.sync_forever(timeout=30000, full_state=True) 788 - except Exception: 789 - log.exception("sync_forever crashed") 790 - raise 791 - finally: 792 - await runner.cleanup() 793 - await http.close() 794 - await client.close() 795 - 796 - 797 - def main(): 798 - asyncio.run(run())
+798
pkgs/archiver-bot/archiver_bot/__init__.py
··· 1 + import asyncio 2 + import json 3 + import logging 4 + import os 5 + import re 6 + import sys 7 + from pathlib import Path 8 + 9 + from urllib.parse import quote 10 + 11 + import aiohttp 12 + from aiohttp import web 13 + from nio import ( 14 + AsyncClient, 15 + AsyncClientConfig, 16 + InviteMemberEvent, 17 + LoginResponse, 18 + MatrixRoom, 19 + MegolmEvent, 20 + RoomMessageText, 21 + ) 22 + from nio.api import Api 23 + from nio.responses import JoinError, JoinResponse 24 + 25 + logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") 26 + log = logging.getLogger("archiver-bot") 27 + 28 + IMDB_RE = re.compile(r"https?://(?:www\.)?imdb\.com/(?:[a-z]{2}/)?title/(tt\d+)") 29 + TMDB_MOVIE_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/movie/(\d+)") 30 + TMDB_TV_RE = re.compile(r"https?://(?:www\.)?themoviedb\.org/tv/(\d+)") 31 + 32 + JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "http://localhost:5055") 33 + JELLYSEERR_API_KEY = os.environ["JELLYSEERR_API_KEY"] 34 + RADARR_API_KEY = os.environ.get("RADARR_API_KEY", "") 35 + SONARR_API_KEY = os.environ.get("SONARR_API_KEY", "") 36 + MATRIX_HOMESERVER = os.environ["MATRIX_HOMESERVER"] 37 + MATRIX_ACCESS_TOKEN = os.environ["MATRIX_ACCESS_TOKEN"] 38 + MATRIX_USER_ID = os.environ.get("MATRIX_USER_ID", "@archiver:lassul.us") 39 + MATRIX_DEVICE_ID = os.environ.get("MATRIX_DEVICE_ID", "ARCHIVER") 40 + STORE_PATH = os.environ.get("STORE_PATH", "/var/lib/archiver-bot") 41 + WEBHOOK_PORT = int(os.environ.get("WEBHOOK_PORT", "8099")) 42 + FLAX_URL = os.environ.get("FLAX_URL", "https://flax.lassul.us") 43 + FLIX_URL = os.environ.get("FLIX_URL", "https://flix.lassul.us") 44 + JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "http://localhost:8096") 45 + JELLYFIN_API_KEY = os.environ.get("JELLYFIN_API_KEY", "") 46 + IPFS_GATEWAY_URL = os.environ.get("IPFS_GATEWAY_URL", "https://ipfs.lassul.us") 47 + MEDIA_PATH_PREFIX = os.environ.get("MEDIA_PATH_PREFIX", "/var/download/") 48 + IPFS_BIN = os.environ.get("IPFS_BIN", "ipfs") 49 + 50 + # Persistent tracking of requested media -> rooms 51 + TRACKING_FILE = Path(STORE_PATH) / "tracked_requests.json" 52 + 53 + 54 + def load_tracked() -> dict: 55 + """Load tracked requests. Schema: {key: [{"room": id, "event": id|None}, ...]}. 56 + Legacy entries (bare room_id strings) are normalized to event=None.""" 57 + try: 58 + raw = json.loads(TRACKING_FILE.read_text()) 59 + except Exception: 60 + return {} 61 + out = {} 62 + for key, entries in raw.items(): 63 + normalized = [] 64 + for e in entries: 65 + if isinstance(e, str): 66 + normalized.append({"room": e, "event": None}) 67 + elif isinstance(e, dict) and e.get("room"): 68 + normalized.append({"room": e["room"], "event": e.get("event")}) 69 + out[key] = normalized 70 + return out 71 + 72 + 73 + def save_tracked(tracked: dict): 74 + """Save tracked requests to disk.""" 75 + TRACKING_FILE.write_text(json.dumps(tracked)) 76 + 77 + 78 + def track_request(media_type: str, tmdb_id: int, room_id: str, event_id: str | None = None): 79 + """Track that a room requested this media; event_id is the originating 80 + message so download notifications can thread back into that conversation.""" 81 + tracked = load_tracked() 82 + key = f"{media_type}:{tmdb_id}" 83 + entries = tracked.get(key, []) 84 + for entry in entries: 85 + if entry["room"] == room_id: 86 + if event_id is not None: 87 + entry["event"] = event_id 88 + break 89 + else: 90 + entries.append({"room": room_id, "event": event_id}) 91 + tracked[key] = entries 92 + save_tracked(tracked) 93 + 94 + 95 + def pop_tracked(media_type: str, tmdb_id: int) -> list[dict]: 96 + """Get and remove tracked entries for this media.""" 97 + tracked = load_tracked() 98 + key = f"{media_type}:{tmdb_id}" 99 + entries = tracked.pop(key, []) 100 + save_tracked(tracked) 101 + return entries 102 + 103 + 104 + def make_flax_link(path: str) -> str: 105 + """Turn a local path like /var/download/movies/Title (Year)/file.mp4 into a flax.lassul.us URL.""" 106 + from urllib.parse import quote 107 + relative = path 108 + if relative.startswith(MEDIA_PATH_PREFIX): 109 + relative = relative[len(MEDIA_PATH_PREFIX):] 110 + return f"{FLAX_URL}/{quote(relative)}" 111 + 112 + 113 + def find_main_video(path: str) -> str | None: 114 + """Find the main video file in a path. If path is a file, return it. If directory, find largest video.""" 115 + if os.path.isfile(path): 116 + return path 117 + if os.path.isdir(path): 118 + video_exts = {".mkv", ".mp4", ".avi", ".m4v", ".ts", ".wmv"} 119 + best = None 120 + best_size = 0 121 + for root, _dirs, files in os.walk(path): 122 + for f in files: 123 + ext = os.path.splitext(f)[1].lower() 124 + if ext in video_exts: 125 + full = os.path.join(root, f) 126 + size = os.path.getsize(full) 127 + if size > best_size: 128 + best = full 129 + best_size = size 130 + return best 131 + return None 132 + 133 + 134 + async def compute_cid(path: str) -> str | None: 135 + """Compute the IPFS CID for a file using ipfs add --only-hash --raw-leaves.""" 136 + video = find_main_video(path) 137 + if not video: 138 + log.warning(f"No video file found for {path}") 139 + return None 140 + import tempfile 141 + import shutil 142 + ipfs_path = tempfile.mkdtemp(prefix="ipfs-hash-") 143 + try: 144 + env = {**os.environ, "IPFS_PATH": ipfs_path} 145 + init_proc = await asyncio.create_subprocess_exec( 146 + IPFS_BIN, "init", 147 + stdout=asyncio.subprocess.PIPE, 148 + stderr=asyncio.subprocess.PIPE, 149 + env=env, 150 + ) 151 + await init_proc.communicate() 152 + proc = await asyncio.create_subprocess_exec( 153 + IPFS_BIN, "add", "--only-hash", "--raw-leaves", "--quieter", video, 154 + stdout=asyncio.subprocess.PIPE, 155 + stderr=asyncio.subprocess.PIPE, 156 + env=env, 157 + ) 158 + try: 159 + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300) 160 + except asyncio.TimeoutError: 161 + proc.kill() 162 + await proc.communicate() 163 + log.warning(f"ipfs add --only-hash timed out for {video}") 164 + return None 165 + if proc.returncode == 0: 166 + cid = stdout.decode().strip() 167 + if cid: 168 + return cid 169 + else: 170 + log.warning(f"ipfs add --only-hash failed for {video}: {stderr.decode().strip()}") 171 + except Exception as e: 172 + log.warning(f"Failed to compute CID for {video}: {e}") 173 + finally: 174 + shutil.rmtree(ipfs_path, ignore_errors=True) 175 + return None 176 + 177 + 178 + async def trigger_jellyfin_scan(session: aiohttp.ClientSession): 179 + """Trigger a Jellyfin library scan and Jellyseerr sync.""" 180 + try: 181 + async with session.post( 182 + f"{JELLYFIN_URL}/Library/Refresh", 183 + headers={"X-Emby-Token": JELLYFIN_API_KEY}, 184 + ) as resp: 185 + log.info(f"Triggered Jellyfin library scan: {resp.status}") 186 + # also trigger Jellyseerr sync so it picks up the new jellyfinMediaId 187 + await jellyseerr_request(session, "/settings/jellyfin/sync", method="POST") 188 + log.info("Triggered Jellyseerr sync") 189 + except Exception as e: 190 + log.warning(f"Failed to trigger library scan: {e}") 191 + 192 + 193 + async def lookup_jellyfin_id(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 194 + """Look up Jellyfin item ID via Jellyseerr's mediaInfo or Jellyfin API.""" 195 + # Try Jellyseerr first — it tracks the Jellyfin media ID 196 + # Try Jellyseerr first 197 + try: 198 + endpoint = f"/movie/{tmdb_id}" if media_type == "movie" else f"/tv/{tmdb_id}" 199 + details = await jellyseerr_request(session, endpoint) 200 + media_info = details.get("mediaInfo") 201 + if media_info: 202 + jf_id = media_info.get("jellyfinMediaId") 203 + if jf_id: 204 + log.info(f"Jellyfin ID from Jellyseerr for {media_type}:{tmdb_id} = {jf_id}") 205 + return jf_id 206 + except Exception as e: 207 + log.warning(f"Jellyseerr Jellyfin ID lookup failed for {media_type}:{tmdb_id}: {e}") 208 + 209 + # Fallback: query Jellyfin directly, searching by provider ID 210 + if JELLYFIN_API_KEY: 211 + try: 212 + item_type = "Movie" if media_type == "movie" else "Series" 213 + async with session.get( 214 + f"{JELLYFIN_URL}/Items", 215 + params={ 216 + "IncludeItemTypes": item_type, 217 + "Recursive": "true", 218 + "Fields": "ProviderIds", 219 + }, 220 + headers={"X-Emby-Token": JELLYFIN_API_KEY}, 221 + ) as resp: 222 + data = await resp.json() 223 + for item in data.get("Items", []): 224 + provider_ids = item.get("ProviderIds", {}) 225 + if str(provider_ids.get("Tmdb")) == str(tmdb_id): 226 + jf_id = item.get("Id") 227 + log.info(f"Jellyfin ID from Jellyfin API for {media_type}:{tmdb_id} = {jf_id}") 228 + return jf_id 229 + except Exception as e: 230 + log.warning(f"Jellyfin API lookup failed for {media_type}:{tmdb_id}: {e}") 231 + 232 + log.info(f"No Jellyfin ID found for {media_type}:{tmdb_id}") 233 + return None 234 + 235 + 236 + async def make_media_links(path: str, is_dir: bool = False, jellyfin_id: str | None = None) -> tuple[str, str]: 237 + """Generate plain and HTML text with all available links for a media path.""" 238 + from urllib.parse import quote 239 + 240 + lines_plain = [] 241 + lines_html = [] 242 + 243 + # Flax link (direct file download) 244 + flax = make_flax_link(path) 245 + lines_plain.append(f"📁 Download: {flax}") 246 + lines_html.append(f'📁 Download: <a href="{flax}">{flax}</a>') 247 + 248 + # Jellyfin link (streaming) 249 + if jellyfin_id: 250 + flix_link = f"{FLIX_URL}/web/#/details?id={jellyfin_id}" 251 + else: 252 + flix_link = FLIX_URL 253 + lines_plain.append(f"🎬 Stream: {flix_link}") 254 + lines_html.append(f'🎬 Stream: <a href="{flix_link}">{flix_link}</a>') 255 + 256 + # IPFS link (compute CID for the file, retry up to 3 times) 257 + cid = None 258 + for attempt in range(3): 259 + cid = await compute_cid(path) 260 + if cid: 261 + break 262 + log.warning(f"IPFS hash attempt {attempt + 1}/3 failed for {path}") 263 + await asyncio.sleep(2) 264 + 265 + if cid: 266 + ipfs_link = f"{IPFS_GATEWAY_URL}/ipfs/{cid}" 267 + lines_plain.append(f"🌐 IPFS: {ipfs_link}") 268 + lines_html.append(f'🌐 IPFS: <a href="{ipfs_link}">{ipfs_link}</a>') 269 + lines_plain.append(f" ipfs get {cid}") 270 + lines_html.append(f" <code>ipfs get {cid}</code>") 271 + else: 272 + log.error(f"IPFS hash failed after 3 attempts for {path}, not posting message") 273 + return None 274 + 275 + return ("\n".join(lines_plain), "<br>".join(lines_html)) 276 + 277 + 278 + async def jellyseerr_request(session: aiohttp.ClientSession, path: str, **kwargs) -> dict: 279 + """Make an authenticated request to Jellyseerr.""" 280 + headers = { 281 + "X-Api-Key": JELLYSEERR_API_KEY, 282 + "Content-Type": "application/json", 283 + "Accept": "application/json", 284 + } 285 + method = kwargs.pop("method", "GET") 286 + url = f"{JELLYSEERR_URL}/api/v1{path}" 287 + async with session.request(method, url, headers=headers, **kwargs) as resp: 288 + return await resp.json() 289 + 290 + 291 + async def find_by_imdb(session: aiohttp.ClientSession, imdb_id: str) -> dict | None: 292 + """Look up a movie or TV show by IMDb ID via Radarr/Sonarr, returning Jellyseerr-compatible data.""" 293 + 294 + # Try Radarr (movies) first 295 + try: 296 + async with session.get( 297 + f"http://localhost:7878/api/v3/movie/lookup?term=imdb:{imdb_id}", 298 + headers={"X-Api-Key": RADARR_API_KEY}, 299 + ) as resp: 300 + results = await resp.json() 301 + if results and len(results) > 0: 302 + tmdb_id = results[0].get("tmdbId") 303 + title = results[0].get("title", "Unknown") 304 + year = str(results[0].get("year", "")) 305 + if tmdb_id: 306 + log.info(f"Radarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 307 + return { 308 + "id": tmdb_id, 309 + "mediaType": "movie", 310 + "title": title, 311 + "releaseDate": f"{year}-01-01" if year else "", 312 + } 313 + except Exception as e: 314 + log.warning(f"Radarr lookup failed: {e}") 315 + 316 + # Try Sonarr (TV shows) 317 + try: 318 + async with session.get( 319 + f"http://localhost:8989/api/v3/series/lookup?term=imdb:{imdb_id}", 320 + headers={"X-Api-Key": SONARR_API_KEY}, 321 + ) as resp: 322 + results = await resp.json() 323 + if results and len(results) > 0: 324 + tmdb_id = results[0].get("tmdbId", 0) 325 + title = results[0].get("title", "Unknown") 326 + year = str(results[0].get("year", "")) 327 + if tmdb_id: 328 + log.info(f"Sonarr resolved {imdb_id} -> TMDB {tmdb_id} ({title})") 329 + return { 330 + "id": tmdb_id, 331 + "mediaType": "tv", 332 + "name": title, 333 + "firstAirDate": f"{year}-01-01" if year else "", 334 + } 335 + except Exception as e: 336 + log.warning(f"Sonarr lookup failed: {e}") 337 + 338 + return None 339 + 340 + 341 + async def lookup_media_path(session: aiohttp.ClientSession, media_type: str, tmdb_id: int) -> str | None: 342 + """Look up the path for a movie file or show folder from Radarr/Sonarr by TMDB ID.""" 343 + try: 344 + if media_type == "movie": 345 + async with session.get( 346 + f"http://localhost:7878/api/v3/movie?tmdbId={tmdb_id}", 347 + headers={"X-Api-Key": RADARR_API_KEY}, 348 + ) as resp: 349 + results = await resp.json() 350 + if results and len(results) > 0: 351 + movie = results[0] 352 + if not movie.get("hasFile"): 353 + return None 354 + movie_file = movie.get("movieFile", {}) 355 + if movie_file and movie_file.get("path"): 356 + return movie_file["path"] 357 + return None 358 + else: 359 + async with session.get( 360 + f"http://localhost:8989/api/v3/series?tvdbId={tmdb_id}", 361 + headers={"X-Api-Key": SONARR_API_KEY}, 362 + ) as resp: 363 + results = await resp.json() 364 + if results and len(results) > 0: 365 + return results[0].get("path") 366 + except Exception as e: 367 + log.warning(f"Media path lookup failed for {media_type}:{tmdb_id}: {e}") 368 + return None 369 + 370 + 371 + async def request_media(session: aiohttp.ClientSession, media: dict, room_id: str, event_id: str | None = None) -> tuple[str, str]: 372 + """Request a movie or TV show via Jellyseerr. Returns (plain, html) status message.""" 373 + media_type = media["mediaType"] 374 + media_id = media["id"] 375 + title = media.get("title") or media.get("name") or "Unknown" 376 + year = (media.get("releaseDate") or media.get("firstAirDate") or "")[:4] 377 + display = f"{title} ({year})" if year else title 378 + 379 + # Check if already available or requested 380 + existing = media.get("mediaInfo") 381 + if existing: 382 + status = existing.get("status") 383 + # status 5 = available, 4 = partially available, 3 = processing, 2 = pending 384 + if status == 5: 385 + path = await lookup_media_path(session, media_type, media_id) 386 + if path: 387 + is_dir = media_type == "tv" 388 + jf_id = await lookup_jellyfin_id(session, media_type, media_id) 389 + links = await make_media_links(path, is_dir=is_dir, jellyfin_id=jf_id) 390 + if not links: 391 + log.error(f"IPFS hash failed for {display}, not posting"); return None 392 + links_plain, links_html = links 393 + return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 394 + return (f"✅ {display} is already available!", f"✅ <b>{display}</b> is already available!") 395 + elif status in (2, 3, 4): 396 + track_request(media_type, media_id, room_id, event_id) 397 + return (f"⏳ {display} has already been requested and is being processed.", f"⏳ <b>{display}</b> has already been requested and is being processed.") 398 + 399 + payload = {"mediaType": media_type, "mediaId": media_id} 400 + if media_type == "tv": 401 + payload["seasons"] = "all" 402 + 403 + # Check if Radarr/Sonarr already has the file before requesting 404 + existing_path = await lookup_media_path(session, media_type, media_id) 405 + if existing_path: 406 + is_dir = media_type == "tv" 407 + jf_id = await lookup_jellyfin_id(session, media_type, media_id) 408 + links = await make_media_links(existing_path, is_dir=is_dir, jellyfin_id=jf_id) 409 + # Still send the request to Jellyseerr so it's tracked there too 410 + await jellyseerr_request(session, "/request", method="POST", json=payload) 411 + if not links: 412 + log.error(f"IPFS hash failed for {display}, not posting"); return None 413 + links_plain, links_html = links 414 + return (f"✅ {display} is already available!\n{links_plain}", f"✅ <b>{display}</b> is already available!<br>{links_html}") 415 + 416 + result = await jellyseerr_request(session, "/request", method="POST", json=payload) 417 + 418 + if "id" in result: 419 + track_request(media_type, media_id, room_id, event_id) 420 + return (f"🎬 Successfully requested {display}! I'll notify you when it's downloaded.", f"🎬 Successfully requested <b>{display}</b>! I'll notify you when it's downloaded.") 421 + else: 422 + error = result.get("message", json.dumps(result)) 423 + return (f"❌ Failed to request {display}: {error}", f"❌ Failed to request <b>{display}</b>: {error}") 424 + 425 + 426 + async def trust_room_devices(client: AsyncClient, room_id: str): 427 + """Trust all devices of all members in a room.""" 428 + if not client.olm: 429 + return 430 + room = client.rooms.get(room_id) 431 + if not room: 432 + return 433 + for user_id in room.users: 434 + for device in client.device_store.active_user_devices(user_id): 435 + if not client.olm.is_device_verified(device): 436 + client.verify_device(device) 437 + log.info(f"Verified device {device.device_id} of {user_id}") 438 + 439 + 440 + async def send_notice(client: AsyncClient, room_id: str, plain: str, html: str, thread_root: str | None = None): 441 + """Send a notice message to a room. If thread_root is given, the message 442 + becomes a reply within that thread (with the in_reply_to fallback so 443 + non-threaded clients still render a quote).""" 444 + await trust_room_devices(client, room_id) 445 + content = { 446 + "msgtype": "m.notice", 447 + "body": plain, 448 + "format": "org.matrix.custom.html", 449 + "formatted_body": html, 450 + } 451 + if thread_root: 452 + content["m.relates_to"] = { 453 + "rel_type": "m.thread", 454 + "event_id": thread_root, 455 + "is_falling_back": True, 456 + "m.in_reply_to": {"event_id": thread_root}, 457 + } 458 + await client.room_send(room_id, "m.room.message", content) 459 + 460 + 461 + def thread_root_for(event) -> str: 462 + """If event is already part of a thread, return that thread's root; 463 + otherwise return event's own id (start a new thread on it).""" 464 + rel = (getattr(event, "source", None) or {}).get("content", {}).get("m.relates_to") or {} 465 + if rel.get("rel_type") == "m.thread" and rel.get("event_id"): 466 + return rel["event_id"] 467 + return event.event_id 468 + 469 + 470 + async def on_message(client: AsyncClient, room: MatrixRoom, event: RoomMessageText, http: aiohttp.ClientSession): 471 + """Handle messages in rooms, looking for IMDb/TMDB links.""" 472 + # Ignore our own messages 473 + if event.sender == client.user_id: 474 + return 475 + 476 + body = event.body 477 + log.info(f"Message from {event.sender} in {room.display_name}: {body[:100]}") 478 + thread_root = thread_root_for(event) 479 + 480 + # Help command 481 + if body.strip().lower() in ("help", "!help"): 482 + plain = ( 483 + "Archiver Bot\n\n" 484 + "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.\n" 485 + "I'll notify you when the download is complete.\n\n" 486 + "Examples:\n" 487 + " https://www.imdb.com/title/tt0133093/\n" 488 + " https://www.themoviedb.org/movie/603\n" 489 + " https://www.themoviedb.org/tv/1399" 490 + ) 491 + html = ( 492 + "<b>Archiver Bot</b><br><br>" 493 + "Send me an IMDb or TMDB link and I'll request the movie/show on Jellyseerr.<br>" 494 + "I'll notify you when the download is complete.<br><br>" 495 + "Examples:<br>" 496 + "<code>https://www.imdb.com/title/tt0133093/</code><br>" 497 + "<code>https://www.themoviedb.org/movie/603</code><br>" 498 + "<code>https://www.themoviedb.org/tv/1399</code>" 499 + ) 500 + await send_notice(client, room.room_id, plain, html, thread_root=thread_root) 501 + return 502 + 503 + # Collect all media references from the message 504 + results = [] 505 + 506 + for imdb_id in set(IMDB_RE.findall(body)): 507 + log.info(f"Found IMDb link {imdb_id} in {room.display_name} from {event.sender}") 508 + media = await find_by_imdb(http, imdb_id) 509 + if media is None: 510 + results.append((f"❓ Could not find anything for {imdb_id}.", f"❓ Could not find anything for <code>{imdb_id}</code>.")) 511 + else: 512 + results.append(await request_media(http, media, room.room_id, thread_root)) 513 + 514 + for tmdb_id in set(TMDB_MOVIE_RE.findall(body)): 515 + log.info(f"Found TMDB movie link {tmdb_id} in {room.display_name} from {event.sender}") 516 + media = {"id": int(tmdb_id), "mediaType": "movie"} 517 + try: 518 + details = await jellyseerr_request(http, f"/movie/{tmdb_id}") 519 + media["title"] = details.get("title", "Unknown") 520 + media["releaseDate"] = details.get("releaseDate", "") 521 + media["mediaInfo"] = details.get("mediaInfo") 522 + except Exception as e: 523 + log.warning(f"Failed to fetch TMDB movie {tmdb_id}: {e}") 524 + results.append(await request_media(http, media, room.room_id, thread_root)) 525 + 526 + for tmdb_id in set(TMDB_TV_RE.findall(body)): 527 + log.info(f"Found TMDB TV link {tmdb_id} in {room.display_name} from {event.sender}") 528 + media = {"id": int(tmdb_id), "mediaType": "tv"} 529 + try: 530 + details = await jellyseerr_request(http, f"/tv/{tmdb_id}") 531 + media["name"] = details.get("name", "Unknown") 532 + media["firstAirDate"] = details.get("firstAirDate", "") 533 + media["mediaInfo"] = details.get("mediaInfo") 534 + except Exception as e: 535 + log.warning(f"Failed to fetch TMDB TV {tmdb_id}: {e}") 536 + results.append(await request_media(http, media, room.room_id, thread_root)) 537 + 538 + for result in results: 539 + if result is not None: 540 + plain, html = result 541 + await send_notice(client, room.room_id, plain, html, thread_root=thread_root) 542 + 543 + 544 + async def on_megolm(client: AsyncClient, room: MatrixRoom, event: MegolmEvent): 545 + """Handle messages we couldn't decrypt.""" 546 + log.warning( 547 + f"Failed to decrypt message from {event.sender} in {room.display_name} " 548 + f"(session {event.session_id})" 549 + ) 550 + # Request the keys we're missing 551 + if client.olm: 552 + try: 553 + resp = await client.request_room_key(event) 554 + log.info(f"Requested room key for session {event.session_id}: {resp}") 555 + except Exception as e: 556 + log.warning(f"Failed to request room key: {e}") 557 + await send_notice( 558 + client, 559 + room.room_id, 560 + "🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.", 561 + "🔒 I couldn't decrypt your message. Please try again or re-invite me to this room.", 562 + thread_root=event.event_id, 563 + ) 564 + 565 + 566 + def _server_name(matrix_id: str | None) -> str | None: 567 + if matrix_id and ":" in matrix_id: 568 + return matrix_id.split(":", 1)[1] 569 + return None 570 + 571 + 572 + async def try_join(client: AsyncClient, room_id: str, via: str | None = None, attempts: int = 6) -> bool: 573 + # client.join doesn't expose server_name; pass it through to help our 574 + # homeserver resolve the room over federation. Retry with backoff because 575 + # invites delivered via sync can briefly race against the server's own 576 + # room-state view (M_FORBIDDEN even though the invite just arrived). 577 + method, path = Api.join(client.access_token, room_id) 578 + if via: 579 + path += "&server_name=" + quote(via) 580 + for attempt in range(1, attempts + 1): 581 + result = await client._send(JoinResponse, method, path) 582 + if not isinstance(result, JoinError): 583 + log.info(f"Joined {room_id} on attempt {attempt}") 584 + return True 585 + log.warning(f"Join attempt {attempt}/{attempts} for {room_id} failed: {result}") 586 + if attempt < attempts: 587 + await asyncio.sleep(2 ** (attempt - 1)) 588 + log.error(f"Gave up joining {room_id} after {attempts} attempts") 589 + return False 590 + 591 + 592 + async def fetch_pending_invites(client: AsyncClient, http: aiohttp.ClientSession) -> list[str]: 593 + # nio's incremental sync uses the persisted next_batch and synapse will 594 + # not re-deliver invites that were already seen in a prior session, so 595 + # client.invited_rooms misses anything the bot saw and failed to join 596 + # before a restart. Ask the server explicitly with a fresh /sync. 597 + url = f"{client.homeserver}/_matrix/client/v3/sync?timeout=0" 598 + headers = {"Authorization": f"Bearer {client.access_token}"} 599 + try: 600 + async with http.get(url, headers=headers) as resp: 601 + data = await resp.json() 602 + return list(data.get("rooms", {}).get("invite", {}).keys()) 603 + except Exception as e: 604 + log.warning(f"Failed to fetch pending invites: {e}") 605 + return [] 606 + 607 + 608 + async def on_invite(client: AsyncClient, room: MatrixRoom, event: InviteMemberEvent): 609 + """Auto-join rooms when invited.""" 610 + log.info(f"Invite event in {room.room_id}: state_key={event.state_key}, sender={event.sender}, membership={event.membership}, our_id={client.user_id}") 611 + if event.state_key != client.user_id: 612 + return 613 + log.info(f"Joining {room.room_id} (invited by {event.sender})...") 614 + await try_join(client, room.room_id, via=_server_name(event.sender)) 615 + 616 + 617 + async def handle_radarr_webhook(request): 618 + """Handle Radarr webhook for download completion.""" 619 + try: 620 + data = await request.json() 621 + event_type = data.get("eventType", "") 622 + log.info(f"Radarr webhook: {event_type}") 623 + 624 + if event_type in ("Download", "MovieFileDelete"): 625 + movie = data.get("movie", {}) 626 + tmdb_id = movie.get("tmdbId") 627 + title = movie.get("title", "Unknown") 628 + year = movie.get("year", "") 629 + display = f"{title} ({year})" if year else title 630 + # Prefer the actual movie file path, fall back to folder 631 + movie_file = data.get("movieFile", {}) 632 + file_path = movie_file.get("path") or movie.get("folderPath", "") 633 + 634 + if event_type == "Download" and tmdb_id: 635 + entries = pop_tracked("movie", tmdb_id) 636 + if entries: 637 + client = request.app["matrix_client"] 638 + http = request.app["http_session"] 639 + # trigger library scan so Jellyfin picks up the new file 640 + await trigger_jellyfin_scan(http) 641 + await asyncio.sleep(10) 642 + jf_id = await lookup_jellyfin_id(http, "movie", tmdb_id) 643 + links = await make_media_links(file_path, jellyfin_id=jf_id) 644 + if not links: 645 + log.error(f"IPFS hash failed for {display}, not notifying") 646 + return web.Response(text="ok") 647 + links_plain, links_html = links 648 + plain = f"✅ {display} has been downloaded and is now available!\n{links_plain}" 649 + html = f"✅ <b>{display}</b> has been downloaded and is now available!<br>{links_html}" 650 + for entry in entries: 651 + room_id = entry["room"] 652 + try: 653 + await send_notice(client, room_id, plain, html, thread_root=entry.get("event")) 654 + except Exception as e: 655 + log.warning(f"Failed to notify room {room_id}: {e}") 656 + except Exception as e: 657 + log.exception(f"Error handling Radarr webhook: {e}") 658 + 659 + return web.Response(text="ok") 660 + 661 + 662 + async def handle_sonarr_webhook(request): 663 + """Handle Sonarr webhook for download completion.""" 664 + try: 665 + data = await request.json() 666 + event_type = data.get("eventType", "") 667 + log.info(f"Sonarr webhook: {event_type}") 668 + 669 + if event_type in ("Download", "EpisodeFileDelete"): 670 + series = data.get("series", {}) 671 + tmdb_id = series.get("tmdbId") 672 + title = series.get("title", "Unknown") 673 + year = series.get("year", "") 674 + display = f"{title} ({year})" if year else title 675 + 676 + episodes = data.get("episodes", []) 677 + ep_info = "" 678 + if episodes: 679 + ep_nums = [f"S{ep.get('seasonNumber', 0):02d}E{ep.get('episodeNumber', 0):02d}" for ep in episodes] 680 + ep_info = f" ({', '.join(ep_nums)})" 681 + 682 + if event_type == "Download" and tmdb_id: 683 + entries = pop_tracked("tv", tmdb_id) 684 + if entries: 685 + client = request.app["matrix_client"] 686 + http = request.app["http_session"] 687 + folder_path = series.get("path", "") 688 + # trigger library scan so Jellyfin picks up the new file 689 + await trigger_jellyfin_scan(http) 690 + await asyncio.sleep(10) 691 + jf_id = await lookup_jellyfin_id(http, "tv", tmdb_id) 692 + links = await make_media_links(folder_path, is_dir=True, jellyfin_id=jf_id) 693 + if not links: 694 + log.error(f"IPFS hash failed for {display}, not notifying") 695 + return web.Response(text="ok") 696 + links_plain, links_html = links 697 + plain = f"✅ {display}{ep_info} has been downloaded and is now available!\n{links_plain}" 698 + html = f"✅ <b>{display}</b>{ep_info} has been downloaded and is now available!<br>{links_html}" 699 + for entry in entries: 700 + room_id = entry["room"] 701 + try: 702 + await send_notice(client, room_id, plain, html, thread_root=entry.get("event")) 703 + except Exception as e: 704 + log.warning(f"Failed to notify room {room_id}: {e}") 705 + except Exception as e: 706 + log.exception(f"Error handling Sonarr webhook: {e}") 707 + 708 + return web.Response(text="ok") 709 + 710 + 711 + async def run(): 712 + """Main bot loop.""" 713 + client_config = AsyncClientConfig( 714 + store_sync_tokens=True, 715 + encryption_enabled=True, 716 + ) 717 + client = AsyncClient( 718 + MATRIX_HOMESERVER, 719 + MATRIX_USER_ID, 720 + device_id=MATRIX_DEVICE_ID, 721 + store_path=STORE_PATH, 722 + config=client_config, 723 + ) 724 + client.access_token = MATRIX_ACCESS_TOKEN 725 + client.user_id = MATRIX_USER_ID 726 + client.load_store() 727 + log.info(f"Crypto store loaded, olm={client.olm is not None}") 728 + 729 + http = aiohttp.ClientSession() 730 + 731 + # Do an initial sync BEFORE registering callbacks to skip old messages 732 + log.info("Performing initial sync...") 733 + resp = await client.sync(timeout=10000, full_state=True) 734 + if hasattr(resp, "next_batch"): 735 + log.info(f"Initial sync complete, next_batch: {resp.next_batch}") 736 + else: 737 + log.error(f"Initial sync failed: {resp}") 738 + await http.close() 739 + await client.close() 740 + sys.exit(1) 741 + 742 + # Join any rooms we have a pending invite for (server-side truth; see 743 + # fetch_pending_invites for why we can't rely on client.invited_rooms). 744 + pending = await fetch_pending_invites(client, http) 745 + for room_id in pending: 746 + log.info(f"Joining pending invite for {room_id}...") 747 + await try_join(client, room_id, via=_server_name(room_id)) 748 + 749 + # E2EE setup 750 + if client.olm: 751 + try: 752 + log.info("Uploading device keys...") 753 + key_upload = await client.keys_upload() 754 + log.info(f"Key upload result: {key_upload}") 755 + except Exception as e: 756 + log.info(f"Key upload skipped: {e}") 757 + 758 + # Trust all known device keys so we can decrypt messages 759 + for user_id in client.device_store.users: 760 + for device in client.device_store.active_user_devices(user_id): 761 + if not client.olm.is_device_verified(device): 762 + client.verify_device(device) 763 + log.info(f"Verified device {device.device_id} of {user_id}") 764 + else: 765 + log.warning("Olm not available after sync — E2EE will not work") 766 + 767 + # Register event callbacks only after initial sync 768 + client.add_event_callback(lambda room, event: on_message(client, room, event, http), RoomMessageText) 769 + client.add_event_callback(lambda room, event: on_invite(client, room, event), InviteMemberEvent) 770 + client.add_event_callback(lambda room, event: on_megolm(client, room, event), MegolmEvent) 771 + 772 + # Start webhook server for Radarr/Sonarr notifications 773 + app = web.Application() 774 + app["matrix_client"] = client 775 + app["http_session"] = http 776 + app.router.add_post("/webhook/radarr", handle_radarr_webhook) 777 + app.router.add_post("/webhook/sonarr", handle_sonarr_webhook) 778 + runner = web.AppRunner(app) 779 + await runner.setup() 780 + site = web.TCPSite(runner, "127.0.0.1", WEBHOOK_PORT) 781 + await site.start() 782 + log.info(f"Webhook server listening on 127.0.0.1:{WEBHOOK_PORT}") 783 + 784 + log.info(f"Bot {MATRIX_USER_ID} is running!") 785 + 786 + try: 787 + await client.sync_forever(timeout=30000, full_state=True) 788 + except Exception: 789 + log.exception("sync_forever crashed") 790 + raise 791 + finally: 792 + await runner.cleanup() 793 + await http.close() 794 + await client.close() 795 + 796 + 797 + def main(): 798 + asyncio.run(run())