From 5a4d507a3283f2e214b1d73eaa93c54cd54e4efe Mon Sep 17 00:00:00 2001 From: atagen Date: Thu, 22 May 2025 22:00:52 +1000 Subject: [PATCH] riir riir --- .envrc | 1 + .gitignore | 4 ++ bin/.ocamlformat | 0 bin/dune | 4 ++ bin/main.ml | 14 +++++++ default.nix | 14 +++++++ dune-project | 26 ++++++++++++ flake.lock | 6 +-- flake.nix | 61 +++++++++++++++++++--------- lib/.ocamlformat | 0 lib/build.ml | 36 +++++++++++++++++ lib/config.ml | 4 ++ lib/dune | 5 +++ lib/parse.ml | 29 +++++++++++++ lib/proc.ml | 94 +++++++++++++++++++++++++++++++++++++++++++ lib/smooooth.ml | 53 ++++++++++++++++++++++++ lib/watch.ml | 15 +++++++ module.nix | 54 ++++++++++++++++--------- nix/obus.nix | 35 ++++++++++++++++ shell.nix | 24 +++++++++++ smooooth.opam | 32 +++++++++++++++ smooooth.sh | 46 --------------------- test/dune | 2 + test/test_smooooth.ml | 0 24 files changed, 472 insertions(+), 87 deletions(-) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 bin/.ocamlformat create mode 100644 bin/dune create mode 100644 bin/main.ml create mode 100644 default.nix create mode 100644 dune-project create mode 100644 lib/.ocamlformat create mode 100644 lib/build.ml create mode 100644 lib/config.ml create mode 100644 lib/dune create mode 100644 lib/parse.ml create mode 100644 lib/proc.ml create mode 100644 lib/smooooth.ml create mode 100644 lib/watch.ml create mode 100644 nix/obus.nix create mode 100644 shell.nix create mode 100644 smooooth.opam delete mode 100755 smooooth.sh create mode 100644 test/dune create mode 100644 test/test_smooooth.ml diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d92b4c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +justfile +.direnv/ +_build/ +result diff --git a/bin/.ocamlformat b/bin/.ocamlformat new file mode 100644 index 0000000..e69de29 diff --git a/bin/dune b/bin/dune new file mode 100644 index 0000000..2601f86 --- /dev/null +++ b/bin/dune @@ -0,0 +1,4 @@ +(executable + (public_name smooooth) + (name main) + (libraries smooooth)) diff --git a/bin/main.ml b/bin/main.ml new file mode 100644 index 0000000..311518d --- /dev/null +++ b/bin/main.ml @@ -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 diff --git a/default.nix b/default.nix new file mode 100644 index 0000000..cef0d61 --- /dev/null +++ b/default.nix @@ -0,0 +1,14 @@ +{ + pkgs, + git, + ocamlPackages, + buildInputs, +}: +ocamlPackages.buildDunePackage { + pname = "smooooth"; + version = "0.01a"; + src = ./.; + meta.mainProgram = "smooooth"; + nativeBuildInputs = [ git ]; + inherit buildInputs; +} diff --git a/dune-project b/dune-project new file mode 100644 index 0000000..6020356 --- /dev/null +++ b/dune-project @@ -0,0 +1,26 @@ +(lang dune 3.18) + +(name smooooth) + +(generate_opam_files true) + +(source + (github username/reponame)) + +(authors "Author Name ") + +(maintainers "Maintainer Name ") + +(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 diff --git a/flake.lock b/flake.lock index aa97b79..ee725c5 100644 --- a/flake.lock +++ b/flake.lock @@ -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": { diff --git a/flake.nix b/flake.nix index 97ec1e7..a7f2bc7 100644 --- a/flake.nix +++ b/flake.nix @@ -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; }; }; } diff --git a/lib/.ocamlformat b/lib/.ocamlformat new file mode 100644 index 0000000..e69de29 diff --git a/lib/build.ml b/lib/build.ml new file mode 100644 index 0000000..b5e0564 --- /dev/null +++ b/lib/build.ml @@ -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 *) diff --git a/lib/config.ml b/lib/config.ml new file mode 100644 index 0000000..ebaaf01 --- /dev/null +++ b/lib/config.ml @@ -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] diff --git a/lib/dune b/lib/dune new file mode 100644 index 0000000..c8a8962 --- /dev/null +++ b/lib/dune @@ -0,0 +1,5 @@ +(library + (name smooooth) + (libraries lwt obus obus.notification inotify.lwt angstrom systemd yojson) + (preprocess + (pps lwt_ppx ppx_deriving_yojson))) diff --git a/lib/parse.ml b/lib/parse.ml new file mode 100644 index 0000000..67c6442 --- /dev/null +++ b/lib/parse.ml @@ -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 '?' diff --git a/lib/proc.ml b/lib/proc.ml new file mode 100644 index 0000000..79997e5 --- /dev/null +++ b/lib/proc.ml @@ -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 diff --git a/lib/smooooth.ml b/lib/smooooth.ml new file mode 100644 index 0000000..c6f263d --- /dev/null +++ b/lib/smooooth.ml @@ -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 () diff --git a/lib/watch.ml b/lib/watch.ml new file mode 100644 index 0000000..48e3801 --- /dev/null +++ b/lib/watch.ml @@ -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 diff --git a/module.nix b/module.nix index f4a05ff..7a98395 100644 --- a/module.nix +++ b/module.nix @@ -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}"; }; }; }; diff --git a/nix/obus.nix b/nix/obus.nix new file mode 100644 index 0000000..e9bbfbb --- /dev/null +++ b/nix/obus.nix @@ -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 + ]; +} diff --git a/shell.nix b/shell.nix new file mode 100644 index 0000000..dd2ff14 --- /dev/null +++ b/shell.nix @@ -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''; +} diff --git a/smooooth.opam b/smooooth.opam new file mode 100644 index 0000000..ab7cba2 --- /dev/null +++ b/smooooth.opam @@ -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 "] +authors: ["Author Name "] +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)"] diff --git a/smooooth.sh b/smooooth.sh deleted file mode 100755 index f16af46..0000000 --- a/smooooth.sh +++ /dev/null @@ -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 diff --git a/test/dune b/test/dune new file mode 100644 index 0000000..323cb2a --- /dev/null +++ b/test/dune @@ -0,0 +1,2 @@ +(test + (name test_smooooth)) diff --git a/test/test_smooooth.ml b/test/test_smooooth.ml new file mode 100644 index 0000000..e69de29