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