riir
This commit is contained in:
atagen 2025-05-22 22:00:52 +10:00
parent d9038b92cc
commit 5a4d507a32
24 changed files with 472 additions and 87 deletions

1
.envrc Normal file
View File

@ -0,0 +1 @@
use flake

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
justfile
.direnv/
_build/
result

0
bin/.ocamlformat Normal file
View File

4
bin/dune Normal file
View File

@ -0,0 +1,4 @@
(executable
(public_name smooooth)
(name main)
(libraries smooooth))

14
bin/main.ml Normal file
View File

@ -0,0 +1,14 @@
let () =
Daemon.notify Daemon.State.Ready |> ignore;
let conf =
(try Yojson.Safe.from_file "/etc/smooooth.json" |> Smooooth.config_of_yojson
with Sys_error e ->
Printf.eprintf "Failed to open config - %s\n" e;
exit 1)
|> function
| Ok c -> c
| Error e ->
Printf.eprintf "Fatal error parsing config - %s\n" e;
exit 1
in
Lwt_main.run @@ Smooooth.main conf.path conf.blockers

14
default.nix Normal file
View File

@ -0,0 +1,14 @@
{
pkgs,
git,
ocamlPackages,
buildInputs,
}:
ocamlPackages.buildDunePackage {
pname = "smooooth";
version = "0.01a";
src = ./.;
meta.mainProgram = "smooooth";
nativeBuildInputs = [ git ];
inherit buildInputs;
}

26
dune-project Normal file
View File

@ -0,0 +1,26 @@
(lang dune 3.18)
(name smooooth)
(generate_opam_files true)
(source
(github username/reponame))
(authors "Author Name <author@example.com>")
(maintainers "Maintainer Name <maintainer@example.com>")
(license LICENSE)
(documentation https://url/to/documentation)
(package
(name smooooth)
(synopsis "A short synopsis")
(description "A longer description")
(depends ocaml)
(tags
("add topics" "to describe" your project)))
; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html

6
flake.lock generated
View File

@ -17,11 +17,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1747728033,
"narHash": "sha256-NnXFQu7g4LnvPIPfJmBuZF7LFy/fey2g2+LCzjQhTUk=",
"lastModified": 1747885982,
"narHash": "sha256-rSuxACdwx5Ndr2thpjqcG89fj8mSSp96CFoCt0yrdkY=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2f9173bde1d3fbf1ad26ff6d52f952f9e9da52ea",
"rev": "a16efe5d2fc7455d7328a01f4692bfec152965b3",
"type": "github"
},
"original": {

View File

@ -8,29 +8,52 @@
inputs:
with inputs;
let
get = builtins.attrValues;
forAllSystems =
function:
nixpkgs.lib.genAttrs (import nix-systems) (system: function nixpkgs.legacyPackages.${system});
deps = forAllSystems (pkgs: {
dev = get {
inherit (pkgs) just;
inherit (pkgs.ocamlPackages)
utop
dune_3
ocaml-lsp
ocamlformat
ocamlformat-rpc-lib
;
};
build =
get {
inherit (pkgs.ocamlPackages)
ocaml
lwt
lwt_ppx
inotify
angstrom
systemd
yojson
ppx_deriving_yojson
;
}
++ [ self.packages.${pkgs.system}.obus ];
});
in
{
packages = forAllSystems (pkgs: {
default =
nix:
pkgs.writeShellApplication {
name = "smooooth";
runtimeInputs = builtins.attrValues {
inherit (pkgs)
gnugrep
coreutils
libnotify
inotify-tools
git
;
inherit nix;
};
text = builtins.readFile ./smooooth.sh;
};
devShells = forAllSystems (pkgs: {
default = import ./shell.nix {
inherit pkgs;
deps = deps.${pkgs.system};
};
});
packages = forAllSystems (pkgs: {
default = pkgs.callPackage ./default.nix {
buildInputs = deps.${pkgs.system}.build;
};
obus = pkgs.callPackage ./nix/obus.nix { };
});
nixosModules.smooooth =
{
config,
@ -40,9 +63,7 @@
}:
{
imports = [ ./module.nix ];
services.smooooth.package = (
self.packages.${pkgs.system}.default config.services.smooooth.nixPackage
);
services.smooooth.package = self.packages.${pkgs.system}.default;
};
};
}

0
lib/.ocamlformat Normal file
View File

36
lib/build.ml Normal file
View File

@ -0,0 +1,36 @@
open Lwt.Syntax
open Lwt.Infix
let run_and_collect cmd =
let* list = Lwt_process.pread_lines cmd |> Lwt_stream.to_list in
list |> List.fold_left (fun acc el -> acc ^ " " ^ el) "" |> Lwt.return
let rebuild_conf path =
let* dir = Lwt_io.create_temp_dir ~suffix:"smooooth" () in
let* hostname = Lwt_unix.gethostname () in
let* nix_build =
Lwt_process.exec
( "nix",
[|
"nix";
"build";
"--out-link";
dir ^ "/system";
path ^ "#nixosConfigurations." ^ hostname
^ ".config.system.build.toplevel";
|] )
in
let* res = match nix_build with
| Unix.WEXITED e when e = 0 ->
Lwt_process.exec
( "pkexec",
[| "pkexec"; dir ^ "/system/bin/switch-to-configuration"; "switch" |] ) >>= fun _ -> Lwt.return_true
| _ -> Lwt.return_false
(* let* () = Lwt_io.printlf "nix build output:\n%s" nix_build in *)
in
(* let* () = Lwt_io.printlf "activation output:\n%s" activation in *)
let* () = Lwt_io.delete_recursively dir in
Lwt.return res
(* buffer four to five lines and keep them up in the notification ? *)
(* nom-like current-build output ? *)
(* catch any build error and try to display it usefully *)

4
lib/config.ml Normal file
View File

@ -0,0 +1,4 @@
type blocker_cond = Stop | Die [@@deriving yojson]
type blocker = { name : string; cond : blocker_cond } [@@deriving yojson]
type blocker_list = blocker list [@@deriving yojson]
type config = { path : string; blockers : blocker_list } [@@deriving yojson]

5
lib/dune Normal file
View File

@ -0,0 +1,5 @@
(library
(name smooooth)
(libraries lwt obus obus.notification inotify.lwt angstrom systemd yojson)
(preprocess
(pps lwt_ppx ppx_deriving_yojson)))

29
lib/parse.ml Normal file
View File

@ -0,0 +1,29 @@
open Angstrom
let is_integer = function '0' .. '9' -> true | _ -> false
let integer = skip_while is_integer
let is_paren = function '(' .. ')' -> true | _ -> false
let paren = satisfy is_paren
let is_whitespace = function ' ' | '\t' -> true | _ -> false
let whitespace = skip_while is_whitespace
let is_newline = function '\n' | '\r' -> true | _ -> false
let newline = satisfy is_newline
let name = skip_while (function '-' .. 'z' | ' ' -> true | _ -> false)
let is_status = function
| 'R' | 'S' | 'D' | 'Z' | 'T' | 't' | 'X' | 'I' -> true
| _ -> false
let status = satisfy is_status
let procname = paren *> name <* paren
(* 8520 (smooooth) R *)
let state =
integer *> whitespace *> procname *> whitespace *> status <* skip_many any_char
let get_state str =
Angstrom.parse_string ~consume:Prefix state str |> function
| Ok s -> Lwt.return s
| Error e ->
Printf.printf "Parser error on: %s\n..while parsing: %s" e str;
Lwt.return '?'

94
lib/proc.ml Normal file
View File

@ -0,0 +1,94 @@
open Lwt.Syntax
open Lwt.Infix
open Config
let read_file path =
let open Lwt_io in
with_file ~mode:Input path (fun f -> f |> read_chars |> Lwt_stream.to_string)
let scrape_pid uid pid =
let proc = "/proc/" ^ pid in
let* ours = Lwt_unix.stat proc >|= fun x -> x.st_uid = uid in
if ours then
let* command = proc ^ "/comm" |> read_file >|= String.trim in
let* state =
let* s = proc ^ "/stat" |> read_file in
s |> Parse.get_state
in
let* cwd = proc ^ "/cwd" |> Lwt_unix.readlink in
Lwt.return_some (String.trim command, state, cwd)
else Lwt.return_none
let scrape_all uid =
let* pids =
Lwt_unix.files_of_directory "/proc"
|> Lwt_stream.filter (fun name ->
String.fold_left
(fun result char ->
if not result then false
else match char with '0' .. '9' -> true | _ -> false)
true name)
|> Lwt_stream.to_list
in
(* scrape procfs for info *)
pids
|> Lwt_list.map_p (fun x ->
Lwt.catch
(fun () -> scrape_pid uid x)
(function
| Unix.Unix_error (_, _, _) ->
(* sick of these errors and i don't fucking care !!! *)
(* let* () = *)
(* Lwt_io.eprintlf "Error scraping pid %s: %s" x *)
(* (Unix.error_message e) *)
(* in *)
Lwt.return_none
| Lwt_io.Channel_closed e ->
let* () = Lwt_io.eprintlf "channel closed: %s" e in
Lwt.return_none
| _ ->
let* () = Lwt_io.eprintlf "Unknown error during pid scrape" in
Lwt.return_none))
let get_blockers ~(blockers : blocker_list) ~config_path
~procdata =
let is_at_rest state cond =
match state with 'T' when cond = Stop -> true | 'X' -> true | _ -> false
in
let* blockers =
procdata
|> Lwt_list.filter_p (function
| Some (cmd, state, cwd) ->
let blocker = List.find_opt (fun b -> b.name = cmd) blockers in
let cond = (Option.value ~default:{name=""; cond=Stop} blocker).cond in
let cmd_block = Option.is_some blocker in
let state_block =
not (is_at_rest state cond)
and cwd_block =
if String.length cwd >= String.length config_path then
String.sub cwd 0 (String.length config_path) = config_path
else false
in
let blocked = cmd_block && state_block && cwd_block in
Lwt.return blocked
(* can we improve error handling here ? *)
| None -> Lwt.return_false)
in
List.length blockers > 0 |> Lwt.return
let rec scrape_loop ~blockers ~config_path ~uid =
(* get all relevant process info *)
let* procdata = scrape_all uid in
(* determine who is blocking our update *)
let* blocked = get_blockers ~blockers ~config_path ~procdata in
(* have a snooze and come back *)
if blocked then
let* () = Lwt_unix.sleep 2.0 in
scrape_loop ~blockers ~config_path ~uid
else Lwt.return_unit
let await_blockers ~config_path ~blockers =
let uid = Unix.getuid () in
scrape_loop ~blockers ~config_path ~uid

53
lib/smooooth.ml Normal file
View File

@ -0,0 +1,53 @@
include Config
open Lwt.Syntax
let ( <| ) = ( @@ )
let send_noti noti ~body ~icon =
let* new_noti =
match !noti with
| Some n ->
Notification.notify ~replace:n ~summary:"smooooth" ~body ~icon ()
| None -> Notification.notify ~summary:"smooooth" ~body ~icon ()
in
noti := Some new_noti;
Lwt.return_unit
let silence silent f = if silent then Lwt.return_unit else f ()
let main config_path blockers =
let noti = ref None in
let rec loop () =
(* TODO different actions depending on event? *)
let* _, _, _, path = Watch.watch_changes config_path in
let main_loop () =
let* () = send_noti noti ~body:"Awaiting blockers." ~icon:"info" in
let* () = Lwt_io.printlf "awaiting blockers" in
let* () = Proc.await_blockers ~config_path ~blockers in
let* () = send_noti noti ~body:"Rebuilding NixOS config." ~icon:"info" in
let* () = Lwt_io.printlf "rebuilding config" in
let* res = Build.rebuild_conf config_path in
let* () = match res with
| true -> let* () =
send_noti noti
~body:"New config built and activated.\nAwaiting further changes."
~icon:"info"
in
Lwt_io.printlf "complete"
| false -> let* () =
send_noti noti
~body:"Error building and activating config.\nAwaiting further changes."
~icon:"info"
in
Lwt_io.printlf "build error" in
loop ()
in
match path with
| Some p when not (p = "flake.lock") -> main_loop ()
| None -> main_loop ()
| Some _ -> loop ()
in
let* () = Lwt_io.printlf "awaiting changes" in
let* () = send_noti noti ~body:"Awaiting config changes." ~icon:"info" in
loop ()

15
lib/watch.ml Normal file
View File

@ -0,0 +1,15 @@
open Lwt.Syntax
let watch_changes path =
let* notify = Lwt_inotify.create () in
let* _watch =
Lwt_inotify.add_watch notify path
[
Inotify.S_Create;
Inotify.S_Close_write;
Inotify.S_Modify;
Inotify.S_Move;
Inotify.S_Delete;
]
in
Lwt_inotify.read notify

View File

@ -13,7 +13,7 @@ in
options.services.smooooth = {
enable = mkEnableOption "the smooooth nixos hot reloader";
blockers = mkOption {
description = "Names of processes that may block reloading when holding a flake (sub)path open.";
description = "names of processes that may block reloading by holding a flake (sub)path open. default state is \"stop\"";
default = [
"nano"
"nvim"
@ -22,21 +22,25 @@ in
"hx"
];
example = ''
[ "hx" ]
[ "hx" { nano = "die"; nvim = "stop"; } { fish = "die"; } ]
'';
type = types.listOf types.str;
type =
with types;
listOf (
either str (
attrsOf (enum [
"stop"
"die"
])
)
);
};
path = mkOption {
description = "Path to the root of your flake.";
description = "path to the root of your flake.";
type = types.str;
};
pollingRate = mkOption {
description = "How frequently to poll for blockers when waiting on a reload.";
default = 10;
type = types.int;
};
nixPackage = mkOption {
description = "Your preferred package providing a `nix` executable.";
description = "preferred package providing a `nix` executable.";
default = pkgs.nix;
type = types.package;
};
@ -47,22 +51,36 @@ in
};
config = lib.mkIf cfg.enable {
# idk probably need more soteria workarounds
security.polkit.enable = lib.mkIf (config.security.soteria.enable == false) true;
environment.etc."smooooth.json".text = builtins.toJSON {
inherit (cfg) path;
blockers =
let
withDefaults = map (
b: if builtins.typeOf b == "string" then { "${b}" = "stop"; } else b
) cfg.blockers;
merged = lib.mergeAttrsList withDefaults;
# FIXME clumsy, could do better
transformed = lib.mapAttrsToList (n: v: {
name = n;
cond = [
(lib.toSentenceCase v)
];
}) merged;
in
transformed;
};
systemd.user = {
services.smooooth = {
enable = true;
path = [
cfg.package
];
wantedBy = [ "graphical-session.target" ];
after = [ "graphical-session.target" ];
serviceConfig = {
Restart = "always";
Type = "simple";
ExecStart =
let
blockers = builtins.concatStringsSep "|" cfg.blockers;
in
"${lib.getExe cfg.package} ${cfg.path} ${blockers} ${toString cfg.pollingRate}";
Type = "notify";
ExecStart = "${lib.getExe cfg.package}";
};
};
};

35
nix/obus.nix Normal file
View File

@ -0,0 +1,35 @@
{
ocamlPackages,
fetchFromGitHub,
...
}:
let
inherit (ocamlPackages)
lwt
lwt_ppx
lwt_log
lwt_react
xmlm
menhir
;
in
ocamlPackages.buildDunePackage rec {
pname = "obus";
version = "1.2.5";
src = fetchFromGitHub {
owner = "ocaml-community";
repo = "obus";
tag = version;
hash = "sha256-Rf79NDhAC1MG8Iyr/V2exTlY6+COKoSSnbkBc8dx/Hg=";
};
nativeBuildInputs = [
menhir
];
propagatedBuildInputs = [
lwt
lwt_ppx
lwt_log
lwt_react
xmlm
];
}

24
shell.nix Normal file
View File

@ -0,0 +1,24 @@
{
pkgs,
deps,
}:
pkgs.mkShell {
packages = deps.dev ++ deps.build;
shellHook =
let
justfile = ''
set quiet
build:
nix build --offline --out-link result
online:
nix build --out-link result
run: build
result/bin/smooooth
local:
dune build
'';
in
''echo "${justfile}" > justfile'';
}

32
smooooth.opam Normal file
View File

@ -0,0 +1,32 @@
# This file is generated by dune, edit dune-project instead
opam-version: "2.0"
synopsis: "A short synopsis"
description: "A longer description"
maintainer: ["Maintainer Name <maintainer@example.com>"]
authors: ["Author Name <author@example.com>"]
license: "LICENSE"
tags: ["add topics" "to describe" "your" "project"]
homepage: "https://github.com/username/reponame"
doc: "https://url/to/documentation"
bug-reports: "https://github.com/username/reponame/issues"
depends: [
"dune" {>= "3.18"}
"ocaml"
"odoc" {with-doc}
]
build: [
["dune" "subst"] {dev}
[
"dune"
"build"
"-p"
name
"-j"
jobs
"@install"
"@runtest" {with-test}
"@doc" {with-doc}
]
]
dev-repo: "git+https://github.com/username/reponame.git"
x-maintenance-intent: ["(latest)"]

View File

@ -1,46 +0,0 @@
SACRED_SPACE="$1"
BLOCKERS="$2"
PERIOD="$3"
scrapePids() {
for pid in /proc/*; do
if [[ "$pid" =~ .*[0-9]*$ ]]; then
if grep -E "$BLOCKERS" -q "$pid"/comm; then
if [[ "$(readlink "$pid"/cwd)" == "$SACRED_SPACE"* ]]; then
echo "$pid with name $(cat "$pid"/comm) is blocking"
return 0
fi
fi
fi
done
return 1
}
echo "Starting up for $SACRED_SPACE with blockers $BLOCKERS and polling of $PERIOD"
while true; do
inotifywait -r -e modify -e move -e create -e delete "$SACRED_SPACE"
notify-send "smooooth" "config change detected. waiting for blockers to resolve.."
while scrapePids; do
echo "found blocker in $SACRED_SPACE, waiting.."
sleep "$PERIOD"
done
echo "building system"
notify-send "smooooth" "rebuilding your nixos config - please stand by"
temp="$(mktemp -d)"
build="$temp/system"
nix build --out-link "$build" "$SACRED_SPACE"#nixosConfigurations."$HOSTNAME".config.system.build.toplevel
echo "built and linked at $build - attempting to activate system"
notify-send "smooooth" "activating your new config"
switch="$build/bin/switch-to-configuration"
/run/wrappers/bin/pkexec "$switch" switch
echo "cleaning up"
rm -r "$temp"
done

2
test/dune Normal file
View File

@ -0,0 +1,2 @@
(test
(name test_smooooth))

0
test/test_smooooth.ml Normal file
View File