From 3f770855e9baca0d5bf0b3d57c3773db8d905a56 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Wed, 4 Dec 2024 12:57:24 -0500 Subject: [PATCH 01/27] fucking around with ncurses --- cli/revault_cli/rebar.config | 3 +- cli/revault_cli/src/revault_cli.app.src | 3 +- cli/revault_cli/src/revault_curses.escript | 605 +++++++++++++++++++++ 3 files changed, 609 insertions(+), 2 deletions(-) create mode 100644 cli/revault_cli/src/revault_curses.escript diff --git a/cli/revault_cli/rebar.config b/cli/revault_cli/rebar.config index fd26ac9..7b69732 100644 --- a/cli/revault_cli/rebar.config +++ b/cli/revault_cli/rebar.config @@ -1 +1,2 @@ -{deps, [argparse]}. +{deps, [argparse, + {cecho, {git, "https://github.com/mazenharake/cecho.git", {branch, "master"}}}]}. diff --git a/cli/revault_cli/src/revault_cli.app.src b/cli/revault_cli/src/revault_cli.app.src index da2169e..dc57dcc 100644 --- a/cli/revault_cli/src/revault_cli.app.src +++ b/cli/revault_cli/src/revault_cli.app.src @@ -5,7 +5,8 @@ {applications, [kernel, stdlib, - argparse + argparse, + cecho ]}, {env,[]}, {modules, []}, diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript new file mode 100644 index 0000000..0e288aa --- /dev/null +++ b/cli/revault_cli/src/revault_curses.escript @@ -0,0 +1,605 @@ +#!/usr/bin/env escript +%%! -noinput -name ncurses_cli -setcookie revault_cookie +-mode(compile). +-include_lib("cecho/include/cecho.hrl"). + +%% Name of the main running host, as specified in `config/vm.args' +-define(DEFAULT_NODE, "revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@")))). +-define(KEY_BACKSPACE, 127). +-define(KEY_CTRLA, 1). +-define(KEY_CTRLE, 5). +-define(KEY_CTRLD, 4). +-define(KEY_TEXT_RANGE(X), % ignore control codes + (not(X < 32) andalso + not(X >= 127 andalso X < 160))). + + + +%% 0 0 1 1 2 2 3 3 4 4 5 5 6 6 6 +%% 0 5 0 5 0 5 0 5 0 5 0 5 0 5 7 +%% ╔══════╤══════╤══════╤════════╤═══════════════╤══════╤═════════════╗ +%% 1 ║ list │ scan │ SYNC │ status │ generate-keys │ seed │ remote-seed ║ +%% ╟──────┴──────┴──────┴────────┴───────────────┴──────┴─────────────╢ +%% 3 ║ Local Node (ok): revault@node() ║ +%% 4 ║ Peer (X): …/peername ║ +%% 5 ║ Dirs: …/?/dir_a, dir_bigger, dir_c, dir_d ║ +%% ╟──────────────────────────────────────────────────────────────────╢ +%% 7 ║ SCAN SYNC ║ +%% ║ dir_a: ... ... ║ +%% ║ dir_bigger: ok ... ║ +%% 10 ║ dir_c: ok ok ║ +%% ║ dir_d: ok X ║ +%% ║ ║ +%% 13 ╚══════════════════════════════════════════════════════════════════╝ +%% 14 ╰─ some status +%% even multi-line... + +%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%% CUSTOMIZING OPTIONS %%% +%%%%%%%%%%%%%%%%%%%%%%%%%%% +menu_order() -> + [list, scan, sync, status, 'generate-keys', seed, 'remote-seed']. + +args() -> + #{list => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"} + ], + scan => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + help => "Local ReVault instance to connect to"}, + #{name => dirs, label => "Dirs", + type => {list, fun parse_list/2}, default => fun default_dirs/1, + help => "List of directories to scan"} + ], + sync => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + help => "Local ReVault instance to connect to"}, + #{name => dirs, label => "Dirs", + type => {list, fun parse_list/2}, default => fun default_dirs/1, + help => "List of directories to scan"}, + #{name => peer, label => "Peer Node", + type => {list, fun parse_list/2}, default => fun default_peers/1, + help => "List of peers"} + ], + status => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"} + ], + 'generate-keys' => [ + #{name => certname, label => "Certificate Name", + % the string regex 'trims' leading and trailing whitespace + type => {string, "[^\\s]+.*[^\\s]+"}, default => "revault", + help => "Name of the key files generated"}, + #{name => path, label => "Certificate Directory", + type => {string, "[^\\s]+.*[^\\s]+"}, default => "./", + help => "Directory where the key files will be placed"} + ], + seed => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"}, + #{name => path, label => "Fork Seed Directory", + type => {string, "[^\\s]+.*[^\\s]+"}, default => "./forked/", + help => "path of the base directory where the forked data will be located."}, + #{name => dirs, label => "Dirs", + type => {list, fun parse_list/2}, default => fun default_dirs/1, + help => "List of directories to fork"} + ], + 'remote-seed' => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"}, + #{name => peer, label => "Peer Node", + type => {string, "[^\\s]+[^,()]*[^\\s,()]+"}, default => fun default_peer/1, + help => "Peer from which to fork a seed"}, + #{name => dirs, label => "Dirs", + %% TODO: replace list by 'peer_dirs' + type => {list, fun parse_list/2}, default => fun default_dirs/1, + help => "List of directories to fork"} + ] + }. + +parse_list(_String, _State) -> + []. + +default_dirs(#{local_node := Node}) -> + try config(Node) of + {config, _Path, Config} -> + #{<<"dirs">> := DirMap} = Config, + maps:keys(DirMap) + catch + _E:_R -> [] + end. + +default_peers(State = #{local_node := Node}) -> + DirList = maps:get(dir_list, State, []), + try config(Node) of + {config, _Path, Config} -> + #{<<"peers">> := PeerMap} = Config, + Needed = ordsets:from_list(DirList), + [Peer + || Peer <- maps:keys(PeerMap), + Dirs <- [maps:get(<<"sync">>, maps:get(Peer, PeerMap))], + ordsets:is_subset(Needed, ordsets:from_list(Dirs))] + catch + _E:_R -> [] + end. + +default_peer(State) -> + %% Ignore dir lists for this call. + case default_peers(State#{dir_list => []}) of + [] -> ""; + [H] -> H; + [H|T] -> + [H, " (", lists:join(", ", T), ")"] + end. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%% DEFINING THE WHOLE UI THINGY %%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +main(_) -> + setup(), + State = state(#{}), + cecho:refresh(), + Pid = self(), + spawn_link(fun F() -> + Pid ! {input, cecho:getch()}, + F() + end), + loop(select_menu(State, list)). + +setup() -> + application:ensure_all_started(cecho), + %% go in character-by-charcter mode + cecho:cbreak(), + %% don't show output + cecho:noecho(), + %% give keypad access + cecho:keypad(?ceSTDSCR, true), + %% initial cursor position + cecho:move(1,1), + ok. + +state(Old) -> + Default = #{ + mode => menu, + hover_menu => hd(menu_order()), + node => ?DEFAULT_NODE, + connected => false, + menu => undefined, + peer => undefined, + dirs => undefined, + args => #{} + }, + Tmp0 = maps:merge(Default, Old), + %% Refresh the layout to show proper coordinates + Tmp1 = show_menu(Tmp0), + Tmp2 = show_action(Tmp1), + Tmp3 = end_table(Tmp2), + cecho:refresh(), + Tmp3. + + +show_menu(State) -> + #{menu := Chosen, hover_menu := Hover, mode := Mode} = State, + StringMenu = [atom_to_list(X) || X <- menu_order()], + TopRow = ["╔═", + lists:join("═╤═", [lists:duplicate(string:length(X), "═") + || X <- StringMenu]), + "═╗"], + MenuRow = ["║ ", lists:join(" │ ", [format_menu(X, Chosen) || X <- StringMenu]), " ║"], + BottomRow = ["╟─", + lists:join("─┴─", [lists:duplicate(string:length(X), "─") + || X <- StringMenu]), + "─╢"], + {_, MenuMap, CoordMap} = lists:foldl( + fun(X, {N,M,C}) -> + XStr = atom_to_list(X), + {N+length(XStr)+3, + M#{X => {1,N}}, + C#{{1,N} => X}} + end, + {2, #{}, #{}}, + menu_order() + ), + Width = string:length(MenuRow)-1, + str(0, 0, TopRow), + str(1, 0, MenuRow), + str(2, 0, BottomRow), + NewState = State#{menu_coords => {{0,0}, {2,Width}}, + menu_map => MenuMap, + menu_coord_map => CoordMap, + menu_init_pos => {1,2}}, + %% set cursor if in menu mode + case {Mode, Hover} of + {menu, Hover} -> + MoveTo = menu_pos(NewState, Hover), + mv(MoveTo); + _ -> + ok + end, + NewState. + +show_action(State = #{mode := menu, + menu_coords := {_, End}}) -> + State#{action_coords => {End, End}}; +show_action(State = #{mode := action, menu := Action, + menu_coords := {_, {MenuY, MaxX}}}) -> + MinY = MenuY, + %% TODO: truncate lines that are too long + {ArgState, Args} = arg_output(State, Action, + maps:get(action_args, State, + maps:get(Action, args(), []))), + MaxY = lists:foldl(fun(Arg, Y) -> + #{line := {Label, Val}} = Arg, + Line = [Label, ": ", Val], + str(Y+1, 0, ["║ ", string:pad(Line, MaxX-3), " ║"]), + Y+1 + end, MinY, Args), + %% Ranges + {_, RevRanges} = lists:foldl(fun(Arg, {Y, Acc}) -> + #{line := {Label, _}} = Arg, + {Y+1, + [Arg#{range => {{Y+1, string:length(Label)+2+2}, {Y+1, MaxX-2}}} | Acc]} + end, {MinY, []}, Args), + Ranges = lists:reverse(RevRanges), + %% Set position + case cecho:getyx() of + {CurY, CurX} when CurY >= MinY, CurY =< MaxY, + CurX >= 2, CurX =< MaxX -> + ok; + _ -> + case Ranges of + [] -> + mv({MinY, 2}); + [#{range := {First, _Last}}|_] -> + mv(First) + end + end, + ArgState#{action_coords => {{MinY,0}, {MaxY,MaxX}}, + action_args => Ranges, + action_init_pos => {MinY, 2}}. + +end_table(State=#{action_coords := {_, {Y,X}}}) -> + str(Y+1, 0, ["╚", lists:duplicate(X-1, "═") ,"╝"]), + str(Y+2, 0, " ╰─ "), + State#{status_coords => {{Y+1,0}, {Y+2,5}}, + status_init_pos => {Y+2,5}}. + +show_status(State=#{status_init_pos := {Y,X}, + menu_coords := {_, {_,Width}}}, + Str) -> + str(Y, X, lists:duplicate(Width-X, $ )), + str(Y, X, Str), + State. + +loop(OldState) -> + State = #{mode := Mode} = state(OldState), + case Mode of + menu -> + receive + {input, Input} -> + {ok, NewState} = handle_menu({input, Input}, State), + loop(NewState) + end; + action -> + #{menu := Action} = State, + receive + {input, Input} -> + {ok, NewState} = handle_action({input, Input}, Action, State), + loop(NewState) + end + end. + +handle_menu({input, Key}, TmpState) -> + Pos = cecho:getyx(), + case Key of + ?ceKEY_RIGHT -> + NewMenu = next(menu_at(TmpState, Pos), menu_order()), + State = select_menu(TmpState, NewMenu), + {ok, State}; + ?ceKEY_LEFT -> + NewMenu = prev(menu_at(TmpState, Pos), menu_order()), + State = select_menu(TmpState, NewMenu), + mv_by({0,0}), + {ok, State}; + $\n -> + Menu = menu_at(TmpState, Pos), + State = enter_menu(TmpState, Menu), + show_status(State, io_lib:format("Entering ~p", [Menu])), + {ok, State}; + UnknownChar -> + State = show_status( + TmpState, + io_lib:format("Unknown menu character: ~w", [UnknownChar]) + ), + {ok, State} + end. + +handle_action({input, ?ceKEY_ESC}, _Action, TmpState = #{menu := _Menu}) -> + %% exit the menu + TmpState2 = TmpState#{mode => menu, menu => undefined}, + %% clear up the arg list + %% TODO: cache by action? + State = maps:without([action_args], TmpState2), + cecho:erase(), + {ok, State}; +handle_action({input, ?ceKEY_DOWN}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + After = lists:dropwhile(fun(#{range := {_, {MaxY,_}}}) -> Y >= MaxY end, Args), + case After of + [#{range := {Pos, _}}|_] -> mv(Pos); + _ -> ok + end, + {ok, State}; +handle_action({input, ?ceKEY_UP}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + Before = lists:takewhile(fun(#{range := {{MinY,_}, _}}) -> Y > MinY end, Args), + case lists:reverse(Before) of + [#{range := {Pos, _}}|_] -> mv(Pos); + _ -> ok + end, + {ok, State}; +handle_action({input, ?ceKEY_LEFT}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, #{range := {{_,MinX},_}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + X > MinX andalso mv_by({0,-1}), + {ok, State}; +handle_action({input, ?ceKEY_RIGHT}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, #{range := {{_,MinX}, {_,MaxX}}, + line := {_, Str}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + X < MaxX andalso X < MinX+string:length(Str) andalso mv_by({0,1}), + {ok, State}; +handle_action({input, ?KEY_CTRLA}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + {value, #{range := {{_,MinX},_}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + mv({Y, MinX}), + {ok, State}; +handle_action({input, ?KEY_CTRLE}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + {value, #{range := {{_,MinX},_}, + line := {_, Str}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + mv({Y,MinX+string:length(Str)}), + {ok, State}; +handle_action({input, ?KEY_BACKSPACE}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X > MinX of + true -> % can go back + Pre = string:slice(Str, 0, (X-MinX)-1), + Post = string:slice(Str, X-MinX), + Edited = [Pre,Post], + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + mv_by({0,-1}), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, ?ceKEY_DEL}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X >= MinX of + true -> % can go back + Pre = string:slice(Str, 0, X-MinX), + Post = string:slice(Str, (X-MinX)+1), + Edited = [Pre,Post], + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, ?KEY_CTRLD}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X >= MinX of + true -> % can go back + Edited = string:slice(Str, 0, X-MinX), + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, Char}, _Action, State = #{action_args := Args}) when ?KEY_TEXT_RANGE(Char) -> + %% text input! + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X < MaxX andalso X >= MinX + andalso X =< MinX+string:length(Str) of + true -> + Pre = string:slice(Str, 0, X-MinX), + Post = string:slice(Str, (X-MinX)), + Edited = string:slice([Pre, Char, Post], 0, MaxX-MinX), + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + mv_by({0,1}), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, UnknownChar}, Action, TmpState) -> + State = show_status( + TmpState, + io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) + ), + {ok, State}. + +mv_by({OffsetY, OffsetX}) -> + {CY, CX} = cecho:getyx(), + cecho:move(CY+OffsetY, CX+OffsetX). + +mv({Y,X}) -> + cecho:move(Y, X). + +str(Y, X, Str) -> + {OrigY, OrigX} = cecho:getyx(), + %% cecho expects a lists of bytes, so we gotta do some fun converting + cecho:mvaddstr(Y, X, binary_to_list(unicode:characters_to_binary(Str))), + cecho:move(OrigY, OrigX). + +prev(K, L) -> next(K, lists:reverse(L)). + +next(_, [N]) -> N; +next(K, [K,N|_]) -> N; +next(K, [_|T]) -> next(K, T). + +menu_at(#{menu_coord_map := CoordMap}, Coord) -> + #{Coord := Menu} = CoordMap, + Menu. + +menu_pos(#{menu_map := M}, Menu) -> + #{Menu := Coord} = M, + Coord. + +enter_menu(State, Menu) -> + State#{mode => action, + menu => Menu}. + +select_menu(State, Menu) -> + mv(menu_pos(State, Menu)), + State#{hover_menu => Menu}. + +format_menu(X, Chosen) -> + case atom_to_list(Chosen) of + X -> string:uppercase(X); + _ -> X + end. + +arg_output(State, Action, Args) -> + arg_output(State, Action, Args, []). + +arg_output(State, _, [], Acc) -> + {State, lists:reverse(Acc)}; +arg_output(State, Action, [Arg=#{line := _}|Args], Acc) -> + %% already tracked + arg_output(State, Action, Args, [Arg|Acc]); +arg_output(State, Action, [#{type := {node, _Regex}}=Arg|Args], Acc) -> + #{name := Name, label := Label, default := Default} = Arg, + #{args := ArgMap} = State, + %% Assume the value here is parsed by some input handler + Val = maps:get(Name, ArgMap, Default), + NodeVal = list_to_atom(Val), + Status = case connect(NodeVal) of + ok -> + case revault_node(NodeVal) of + ok -> "ok"; + _ -> "?" + end; + _ -> + "X" + end, + Line = {[Label, " (", Status, ")"], Val}, + arg_output(State#{local_node => NodeVal}, Action, Args, + [Arg#{line => Line, val => NodeVal}|Acc]); +arg_output(State, Action, [#{name := dirs, type := {list, _Fun}}=Arg|Args], Acc) -> + #{name := Name, label := Label, default := DefaultFun} = Arg, + #{args := ArgMap} = State, + %% Assume the value here is parsed by some input handler + DirList = maps:get(Name, ArgMap, DefaultFun(State)), + Line = {Label, lists:join(", ", DirList)}, + arg_output(State#{dir_list => DirList}, Action, Args, + [Arg#{line => Line, val => DirList}|Acc]); +arg_output(State, Action, [#{type := {list, _Fun}}=Arg|Args], Acc) -> + #{name := Name, label := Label, default := DefaultFun} = Arg, + #{args := ArgMap} = State, + %% Assume the value here is parsed by some input handler + DirList = maps:get(Name, ArgMap, DefaultFun(State)), + Line = {Label, lists:join(", ", DirList)}, + arg_output(State, Action, Args, + [Arg#{line => Line, val => DirList}|Acc]); +arg_output(State, Action, [#{type := {string, _Regex}}=Arg|Args], Acc) -> + #{name := Name, label := Label, default := DefaultArg} = Arg, + #{args := ArgMap} = State, + Default = if is_function(DefaultArg, 1) -> DefaultArg(State); + is_function(DefaultArg) -> error(bad_arity); + true -> DefaultArg + end, + Val = maps:get(Name, ArgMap, Default), + Line = {Label, Val}, + arg_output(State, Action, Args, + [Arg#{line => Line, val => Val}|Acc]); +arg_output(State, Action, [#{type := Unsupported}=Arg|Args], Acc) -> + #{label := Label} = Arg, + Line = {io_lib:format("[Unsupported] ~ts", [Label]), + io_lib:format("~p", [Unsupported])}, + arg_output(State, Action, Args, + [Arg#{line => Line}|Acc]). + +replace([H|T], H, R) -> [R|T]; +replace([H|T], S, R) -> [H|replace(T, S, R)]. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%% IMPLEMENTATION HELPERS %%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +connect(Node) -> + case net_kernel:connect_node(Node) of + ignored -> {error, no_dist}; + false -> {error, connection_failed}; + true -> ok + end. + +-spec revault_node(atom()) -> ok | {error, term()}. +revault_node(Node) -> + try rpc:call(Node, maestro_loader, status, []) of + current -> ok; + outdated -> ok; + last_valid -> ok; + _ -> {error, unknown_status} + catch + E:R -> {error, {rpc, {E,R}}} + end. + +config(Node) -> + {ok, Path, Config} = rpc:call(Node, maestro_loader, current, []), + {config, Path, Config}. + + From 29534f9d9b05732c5865746abc0af767e9eb661c Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 11 Jan 2025 18:46:58 -0500 Subject: [PATCH 02/27] Validate and parse input via ncurses Every time a value is changed, parse it to update a value in the background, and possibly refresh checks (eg. node connectivity). This clarifies some phases of each argument's parsing and steps across 3 states (managed by data contents) around attributes (`unparsed` => need to check, `val` => parsed, `line` => ready to display). Node validation can time out after a while over the network, so do that check with an upper boundary on execution time. Also, to avoid inconsistencies on cursor positionment, keep label length constant. --- cli/revault_cli/src/revault_curses.escript | 184 +++++++++++++++------ 1 file changed, 136 insertions(+), 48 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 0e288aa..f003108 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -4,7 +4,7 @@ -include_lib("cecho/include/cecho.hrl"). %% Name of the main running host, as specified in `config/vm.args' --define(DEFAULT_NODE, "revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@")))). +-define(DEFAULT_NODE, list_to_atom("revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@"))))). -define(KEY_BACKSPACE, 127). -define(KEY_CTRLA, 1). -define(KEY_CTRLE, 5). @@ -13,6 +13,14 @@ (not(X < 32) andalso not(X >= 127 andalso X < 160))). +-define(MAX_VALIDATION_DELAY, 150). % longest time to validate input, in ms +-define(LOG(X), + ok). + %(fun() -> + % {ok, IoH} = file:open("/tmp/revaultlogcli", [append]), + % file:write(IoH, io_lib:format("~p~n", [X])), + % file:close(IoH) + %end)()). %% 0 0 1 1 2 2 3 3 4 4 5 5 6 6 6 @@ -104,8 +112,31 @@ args() -> ] }. -parse_list(_String, _State) -> - []. +parse_list(String, State) -> + try + %% drop surrounding whitespace and split on commas + S = string:trim(String, both), + L = re:split(S, "[\\s]*,[\\s]*", [{return, binary}, trim]), + %% ignore empty results (<<>>) in returned value + {ok, [B || B <- L, B =/= <<>>], State} + catch + _:_ -> {error, invalid, State} + end. + +parse_regex(Re, String, State) -> + case re:run(String, Re, [{capture, first, list}]) of + {match, [Str]} -> {ok, Str, State}; + nomatch -> {error, invalid, State} + end. + +parse_with_fun(node, F, Str, State) -> + maybe + {ok, NewStr, NewState} ?= F(Str, State), + Node = list_to_atom(NewStr), + {ok, Node, NewState} + end; +parse_with_fun(_Type, F, Str, State) -> + F(Str, State). default_dirs(#{local_node := Node}) -> try config(Node) of @@ -154,6 +185,7 @@ main(_) -> loop(select_menu(State, list)). setup() -> + logger:remove_handler(default), application:ensure_all_started(cecho), %% go in character-by-charcter mode cecho:cbreak(), @@ -254,6 +286,7 @@ show_action(State = #{mode := action, menu := Action, CurX >= 2, CurX =< MaxX -> ok; _ -> + %% Outside the box, move it to a known location case Ranges of [] -> mv({MinY, 2}); @@ -399,7 +432,8 @@ handle_action({input, ?KEY_BACKSPACE}, _Action, State = #{action_args := Args}) false -> Str end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), {ok, State#{action_args=>NewArgs}}; handle_action({input, ?ceKEY_DEL}, _Action, State = #{action_args := Args}) -> {Y,X} = cecho:getyx(), @@ -420,7 +454,8 @@ handle_action({input, ?ceKEY_DEL}, _Action, State = #{action_args := Args}) -> false -> Str end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), {ok, State#{action_args=>NewArgs}}; handle_action({input, ?KEY_CTRLD}, _Action, State = #{action_args := Args}) -> {Y,X} = cecho:getyx(), @@ -439,7 +474,8 @@ handle_action({input, ?KEY_CTRLD}, _Action, State = #{action_args := Args}) -> false -> Str end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), {ok, State#{action_args=>NewArgs}}; handle_action({input, Char}, _Action, State = #{action_args := Args}) when ?KEY_TEXT_RANGE(Char) -> %% text input! @@ -463,7 +499,8 @@ handle_action({input, Char}, _Action, State = #{action_args := Args}) when ?KEY_ false -> Str end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}}), + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), {ok, State#{action_args=>NewArgs}}; handle_action({input, UnknownChar}, Action, TmpState) -> State = show_status( @@ -518,60 +555,79 @@ arg_output(State, Action, Args) -> arg_output(State, _, [], Acc) -> {State, lists:reverse(Acc)}; -arg_output(State, Action, [Arg=#{line := _}|Args], Acc) -> - %% already tracked +arg_output(State, Action, [Arg|Args], Acc) when not is_map_key(val, Arg) -> + {NewState, NewArg} = arg_init(State, Action, Arg), + arg_output(NewState, Action, [NewArg|Args], Acc); +arg_output(State, Action, [Arg=#{unparsed := Unparsed}|Args], Acc) -> + %% refresh data of pre-parsed elements. + %% with the new value in place, apply the transformation to its internal + %% format for further commands + #{type := TypeInfo} = Arg, + Ret = case TypeInfo of + {T, F} when is_function(F) -> + parse_with_fun(T, F, Unparsed, State); + {T, Regex} when is_list(Regex); is_binary(Regex) -> + F = fun(String, St) -> parse_regex(Regex, String, St) end, + parse_with_fun(T, F, Unparsed, State) + end, + %% Store it all! + ?LOG({?LINE, parsed, maps:get(name, Arg), element(2, Ret)}), + case Ret of + {ok, Val, NewState} -> + arg_output(NewState, Action, + [maps:without([line, unparsed], Arg#{val => Val}) | Args], Acc); + {error, _Reason, NewState} -> + %% TODO: update status? + arg_output(NewState, Action, [maps:without([unparsed], Arg)|Args], Acc) + end; +arg_output(State, Action, [Arg=#{line := _} | Args], Acc) -> arg_output(State, Action, Args, [Arg|Acc]); -arg_output(State, Action, [#{type := {node, _Regex}}=Arg|Args], Acc) -> - #{name := Name, label := Label, default := Default} = Arg, - #{args := ArgMap} = State, - %% Assume the value here is parsed by some input handler - Val = maps:get(Name, ArgMap, Default), - NodeVal = list_to_atom(Val), - Status = case connect(NodeVal) of +arg_output(State, Action, [#{type := {node, _}, label := Label, val := NodeVal}=Arg|Args], Acc) -> + Status = case connect_nonblocking(NodeVal) of ok -> case revault_node(NodeVal) of ok -> "ok"; - _ -> "?" + _ -> "?!" end; + timeout -> + "??"; _ -> - "X" + "!!" end, - Line = {[Label, " (", Status, ")"], Val}, - arg_output(State#{local_node => NodeVal}, Action, Args, - [Arg#{line => Line, val => NodeVal}|Acc]); -arg_output(State, Action, [#{name := dirs, type := {list, _Fun}}=Arg|Args], Acc) -> - #{name := Name, label := Label, default := DefaultFun} = Arg, - #{args := ArgMap} = State, - %% Assume the value here is parsed by some input handler - DirList = maps:get(Name, ArgMap, DefaultFun(State)), - Line = {Label, lists:join(", ", DirList)}, - arg_output(State#{dir_list => DirList}, Action, Args, - [Arg#{line => Line, val => DirList}|Acc]); -arg_output(State, Action, [#{type := {list, _Fun}}=Arg|Args], Acc) -> - #{name := Name, label := Label, default := DefaultFun} = Arg, - #{args := ArgMap} = State, - %% Assume the value here is parsed by some input handler - DirList = maps:get(Name, ArgMap, DefaultFun(State)), + Line = {[Label, " (", Status, ")"], atom_to_list(NodeVal)}, + arg_output(State#{local_node => NodeVal}, Action, + [Arg#{line => Line}|Args], Acc); +arg_output(State, Action, [#{name := dirs, type := {list, _}, label := Label, val := DirList}=Arg|Args], Acc) -> Line = {Label, lists:join(", ", DirList)}, - arg_output(State, Action, Args, - [Arg#{line => Line, val => DirList}|Acc]); -arg_output(State, Action, [#{type := {string, _Regex}}=Arg|Args], Acc) -> - #{name := Name, label := Label, default := DefaultArg} = Arg, - #{args := ArgMap} = State, - Default = if is_function(DefaultArg, 1) -> DefaultArg(State); - is_function(DefaultArg) -> error(bad_arity); - true -> DefaultArg - end, - Val = maps:get(Name, ArgMap, Default), + arg_output(State#{dir_list => DirList}, Action, + [Arg#{line => Line}|Args], Acc); +arg_output(State, Action, [#{type := {list, _}, label := Label, val := List}=Arg|Args], Acc) -> + Line = {Label, lists:join(", ", List)}, + arg_output(State, Action, [Arg#{line => Line}|Args], Acc); +arg_output(State, Action, [#{type := {string, _}, label := Label, val := Val}=Arg|Args], Acc) -> Line = {Label, Val}, - arg_output(State, Action, Args, - [Arg#{line => Line, val => Val}|Acc]); + arg_output(State, Action, [Arg#{line => Line}|Args], Acc); arg_output(State, Action, [#{type := Unsupported}=Arg|Args], Acc) -> #{label := Label} = Arg, Line = {io_lib:format("[Unsupported] ~ts", [Label]), io_lib:format("~p", [Unsupported])}, - arg_output(State, Action, Args, - [Arg#{line => Line}|Acc]). + arg_output(State, Action, [Arg#{line => Line}|Args], Acc). + +arg_init(State, _Action, Arg = #{type := {node, _}, default := Default}) -> + {State#{local_node => Default}, Arg#{val => Default}}; +arg_init(State, _Action, Arg = #{name := dirs, type := {list, _}, default := F}) -> + Default = F(State), + {State#{dir_list => Default}, Arg#{val => Default}}; +arg_init(State, _Action, Arg = #{type := {list, _}, default := F}) -> + {State, Arg#{val => F(State)}}; +arg_init(State, _Action, Arg = #{type := {string, _}, default := X}) -> + Default = if is_function(X, 1) -> X(State); + is_function(X) -> error(bad_arity); + true -> X + end, + {State, Arg#{val => Default}}; +arg_init(State, _Action, Arg = #{type := Unsupported}) -> + {State, Arg#{val => {error, Unsupported}}}. replace([H|T], H, R) -> [R|T]; replace([H|T], S, R) -> [H|replace(T, S, R)]. @@ -587,6 +643,38 @@ connect(Node) -> true -> ok end. +connect_nonblocking(Node) -> + timeout_call(?MAX_VALIDATION_DELAY, fun() -> connect(Node) end). + +%% small helper that defers a blocking call that can be long +%% to another process, such that the validation step can have a +%% ceiling for how long it takes before returning a value. +%% If the process times out, it is killed brutally. +timeout_call(Timeout, Fun) -> + P = self(), + R = make_ref(), + {Pid, Ref} = spawn_monitor(fun() -> + Res = Fun(), + P ! {R, Res} + end), + receive + {R, Res} -> + erlang:demonitor(R, [flush]), + Res; + {'DOWN', Ref, _, _, _} -> + {error, connection_attempt_failed} + after Timeout -> + erlang:exit(Pid, kill), + receive + {'DOWN', Ref, _, _, _} -> + timeout; + {R, Res} -> + erlang:demonitor(R, [flush]), + Res + end + end. + + -spec revault_node(atom()) -> ok | {error, term()}. revault_node(Node) -> try rpc:call(Node, maestro_loader, status, []) of From 2d550307146766901223132f9f282858a4dca36e Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sun, 12 Jan 2025 17:09:41 -0500 Subject: [PATCH 03/27] do validation of arguments and prepare for execution mode --- cli/revault_cli/src/revault_curses.escript | 187 +++++++++++++++------ 1 file changed, 140 insertions(+), 47 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index f003108..e2d6341 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -9,6 +9,7 @@ -define(KEY_CTRLA, 1). -define(KEY_CTRLE, 5). -define(KEY_CTRLD, 4). +-define(KEY_ENTER, 10). -define(KEY_TEXT_RANGE(X), % ignore control codes (not(X < 32) andalso not(X >= 127 andalso X < 160))). @@ -51,63 +52,63 @@ menu_order() -> args() -> #{list => [ #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, help => "ReVault instance to connect to"} ], scan => [ #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, help => "Local ReVault instance to connect to"}, #{name => dirs, label => "Dirs", - type => {list, fun parse_list/2}, default => fun default_dirs/1, + type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, help => "List of directories to scan"} ], sync => [ #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, help => "Local ReVault instance to connect to"}, #{name => dirs, label => "Dirs", - type => {list, fun parse_list/2}, default => fun default_dirs/1, + type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, help => "List of directories to scan"}, #{name => peer, label => "Peer Node", - type => {list, fun parse_list/2}, default => fun default_peers/1, + type => {string, "^(?:\\s*)?(.+)(?:\\s*)?$", fun check_peer/2}, default => fun default_peers/1, help => "List of peers"} ], status => [ #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, help => "ReVault instance to connect to"} ], 'generate-keys' => [ #{name => certname, label => "Certificate Name", % the string regex 'trims' leading and trailing whitespace - type => {string, "[^\\s]+.*[^\\s]+"}, default => "revault", + type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "revault", help => "Name of the key files generated"}, #{name => path, label => "Certificate Directory", - type => {string, "[^\\s]+.*[^\\s]+"}, default => "./", + type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "./", help => "Directory where the key files will be placed"} ], seed => [ #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, help => "ReVault instance to connect to"}, #{name => path, label => "Fork Seed Directory", - type => {string, "[^\\s]+.*[^\\s]+"}, default => "./forked/", + type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "./forked/", help => "path of the base directory where the forked data will be located."}, #{name => dirs, label => "Dirs", - type => {list, fun parse_list/2}, default => fun default_dirs/1, + type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, help => "List of directories to fork"} ], 'remote-seed' => [ #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+"}, default => ?DEFAULT_NODE, + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, help => "ReVault instance to connect to"}, #{name => peer, label => "Peer Node", - type => {string, "[^\\s]+[^,()]*[^\\s,()]+"}, default => fun default_peer/1, + type => {string, "^(?:\\s*)?(.+)(?:\\s*)?$", fun check_peer/2}, default => fun default_peers/1, help => "Peer from which to fork a seed"}, #{name => dirs, label => "Dirs", %% TODO: replace list by 'peer_dirs' - type => {list, fun parse_list/2}, default => fun default_dirs/1, + type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, help => "List of directories to fork"} ] }. @@ -116,15 +117,15 @@ parse_list(String, State) -> try %% drop surrounding whitespace and split on commas S = string:trim(String, both), - L = re:split(S, "[\\s]*,[\\s]*", [{return, binary}, trim]), + L = re:split(S, "[\\s]*,[\\s]*", [{return, binary}]), %% ignore empty results (<<>>) in returned value - {ok, [B || B <- L, B =/= <<>>], State} + {ok, [B || B <- L], State} catch _:_ -> {error, invalid, State} end. parse_regex(Re, String, State) -> - case re:run(String, Re, [{capture, first, list}]) of + case re:run(String, Re, [{capture, first, binary}]) of {match, [Str]} -> {ok, Str, State}; nomatch -> {error, invalid, State} end. @@ -132,7 +133,7 @@ parse_regex(Re, String, State) -> parse_with_fun(node, F, Str, State) -> maybe {ok, NewStr, NewState} ?= F(Str, State), - Node = list_to_atom(NewStr), + Node = binary_to_atom(NewStr), {ok, Node, NewState} end; parse_with_fun(_Type, F, Str, State) -> @@ -153,23 +154,65 @@ default_peers(State = #{local_node := Node}) -> {config, _Path, Config} -> #{<<"peers">> := PeerMap} = Config, Needed = ordsets:from_list(DirList), - [Peer - || Peer <- maps:keys(PeerMap), - Dirs <- [maps:get(<<"sync">>, maps:get(Peer, PeerMap))], - ordsets:is_subset(Needed, ordsets:from_list(Dirs))] + Peers = [Peer + || Peer <- maps:keys(PeerMap), + Dirs <- [maps:get(<<"sync">>, maps:get(Peer, PeerMap))], + ordsets:is_subset(Needed, ordsets:from_list(Dirs))], + lists:join(", ", Peers) catch _E:_R -> [] end. -default_peer(State) -> - %% Ignore dir lists for this call. - case default_peers(State#{dir_list => []}) of - [] -> ""; - [H] -> H; - [H|T] -> - [H, " (", lists:join(", ", T), ")"] +check_connect(_State, Node) -> + case connect_nonblocking(Node) of + ok -> + case revault_node(Node) of + ok -> ok; + _ -> {error, non_revault_node} + end; + timeout -> + {error, connection_timeout}; + _ -> + {error, connection_failure} + end. + +check_dirs(#{local_node := Node}, Dirs) -> + try config(Node) of + {config, _Path, Config} -> + #{<<"dirs">> := DirMap} = Config, + ValidDirs = maps:keys(DirMap), + case Dirs -- ValidDirs of + [] -> ok; + Others -> {error, {unknown_dirs, Others}} + end + catch + _E:_R -> [] end. +check_peer(State = #{local_node := Node}, Peer) -> + DirList = maps:get(dir_list, State, []), + try config(Node) of + {config, _Path, Config} -> + #{<<"peers">> := PeerMap} = Config, + Peers = [ValidPeer + || ValidPeer <- maps:keys(PeerMap)], + case lists:member(Peer, Peers) of + true -> + Needed = ordsets:from_list(DirList), + PeerDirs = maps:get(<<"sync">>, maps:get(Peer, PeerMap, #{}), []), + case ordsets:is_subset(Needed, ordsets:from_list(PeerDirs)) of + true -> ok; + false -> {error, {mismatching_dirs, Peer, Needed, PeerDirs}} + end; + false -> + {error, {unknown_peer, Peer, Peers}} + end + catch + _E:_R -> [] + end. + +check_ignore(_, _) -> + ok. %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%% DEFINING THE WHOLE UI THINGY %%% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% @@ -307,7 +350,8 @@ end_table(State=#{action_coords := {_, {Y,X}}}) -> show_status(State=#{status_init_pos := {Y,X}, menu_coords := {_, {_,Width}}}, Str) -> - str(Y, X, lists:duplicate(Width-X, $ )), + {MaxY,_} = cecho:getmaxyx(), + [str(LY, X, lists:duplicate(Width-X, $\s)) || LY <- lists:seq(Y,MaxY)], str(Y, X, Str), State. @@ -502,6 +546,53 @@ handle_action({input, Char}, _Action, State = #{action_args := Args}) when ?KEY_ NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, unparsed => NewStr}), {ok, State#{action_args=>NewArgs}}; +handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> + %% revalidate all values in all ranges; if any error + %% is found, show it in the status line. + %% if none are found, extract as clean options, and + %% switch to execution mode. + {Errors, Status} = lists:foldl( + fun(Arg = #{line := {_Label, Str}}, {Acc, S}) -> + case parse_arg(TmpState, Action, Arg, Str) of + {ok, _, _} -> {Acc, S}; + {error, Reason, _} -> {[{Arg, Reason}|Acc], error} + end + end, + {[], ok}, + Args + ), + case Status of + ok -> + {Valid, Invalid} = lists:foldl( + fun(Arg = #{val := Val, type := {_,_,F}}, {V,I}) -> + case F(TmpState, Val) of + ok -> {[Arg|V], I}; + {error, Reason} -> {V, [{Arg, Reason}]} + end + end, + {[],[]}, + Args + ), + case {Valid, Invalid} of + {_, []} -> + %% TODO: change state to execution + State = show_status(TmpState, "ok."), + {ok, State}; + {_, [{#{line := {Label, _}}, Reason}|_]} -> + State = show_status( + TmpState, + io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) + ), + {ok, State} + end; + error -> + [{#{line := {Label, _}}, Reason}|_] = Errors, + State = show_status( + TmpState, + io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) + ), + {ok, State} + end; handle_action({input, UnknownChar}, Action, TmpState) -> State = show_status( TmpState, @@ -562,14 +653,7 @@ arg_output(State, Action, [Arg=#{unparsed := Unparsed}|Args], Acc) -> %% refresh data of pre-parsed elements. %% with the new value in place, apply the transformation to its internal %% format for further commands - #{type := TypeInfo} = Arg, - Ret = case TypeInfo of - {T, F} when is_function(F) -> - parse_with_fun(T, F, Unparsed, State); - {T, Regex} when is_list(Regex); is_binary(Regex) -> - F = fun(String, St) -> parse_regex(Regex, String, St) end, - parse_with_fun(T, F, Unparsed, State) - end, + Ret = parse_arg(State, Action, Arg, Unparsed), %% Store it all! ?LOG({?LINE, parsed, maps:get(name, Arg), element(2, Ret)}), case Ret of @@ -582,7 +666,7 @@ arg_output(State, Action, [Arg=#{unparsed := Unparsed}|Args], Acc) -> end; arg_output(State, Action, [Arg=#{line := _} | Args], Acc) -> arg_output(State, Action, Args, [Arg|Acc]); -arg_output(State, Action, [#{type := {node, _}, label := Label, val := NodeVal}=Arg|Args], Acc) -> +arg_output(State, Action, [#{type := {node, _, _}, label := Label, val := NodeVal}=Arg|Args], Acc) -> Status = case connect_nonblocking(NodeVal) of ok -> case revault_node(NodeVal) of @@ -597,14 +681,14 @@ arg_output(State, Action, [#{type := {node, _}, label := Label, val := NodeVal}= Line = {[Label, " (", Status, ")"], atom_to_list(NodeVal)}, arg_output(State#{local_node => NodeVal}, Action, [Arg#{line => Line}|Args], Acc); -arg_output(State, Action, [#{name := dirs, type := {list, _}, label := Label, val := DirList}=Arg|Args], Acc) -> +arg_output(State, Action, [#{name := dirs, type := {list, _, _}, label := Label, val := DirList}=Arg|Args], Acc) -> Line = {Label, lists:join(", ", DirList)}, arg_output(State#{dir_list => DirList}, Action, [Arg#{line => Line}|Args], Acc); -arg_output(State, Action, [#{type := {list, _}, label := Label, val := List}=Arg|Args], Acc) -> +arg_output(State, Action, [#{type := {list, _, _}, label := Label, val := List}=Arg|Args], Acc) -> Line = {Label, lists:join(", ", List)}, arg_output(State, Action, [Arg#{line => Line}|Args], Acc); -arg_output(State, Action, [#{type := {string, _}, label := Label, val := Val}=Arg|Args], Acc) -> +arg_output(State, Action, [#{type := {string, _, _}, label := Label, val := Val}=Arg|Args], Acc) -> Line = {Label, Val}, arg_output(State, Action, [Arg#{line => Line}|Args], Acc); arg_output(State, Action, [#{type := Unsupported}=Arg|Args], Acc) -> @@ -613,14 +697,14 @@ arg_output(State, Action, [#{type := Unsupported}=Arg|Args], Acc) -> io_lib:format("~p", [Unsupported])}, arg_output(State, Action, [Arg#{line => Line}|Args], Acc). -arg_init(State, _Action, Arg = #{type := {node, _}, default := Default}) -> +arg_init(State, _Action, Arg = #{type := {node, _, _}, default := Default}) -> {State#{local_node => Default}, Arg#{val => Default}}; -arg_init(State, _Action, Arg = #{name := dirs, type := {list, _}, default := F}) -> +arg_init(State, _Action, Arg = #{name := dirs, type := {list, _, _}, default := F}) -> Default = F(State), {State#{dir_list => Default}, Arg#{val => Default}}; -arg_init(State, _Action, Arg = #{type := {list, _}, default := F}) -> +arg_init(State, _Action, Arg = #{type := {list, _, _}, default := F}) -> {State, Arg#{val => F(State)}}; -arg_init(State, _Action, Arg = #{type := {string, _}, default := X}) -> +arg_init(State, _Action, Arg = #{type := {string, _, _}, default := X}) -> Default = if is_function(X, 1) -> X(State); is_function(X) -> error(bad_arity); true -> X @@ -629,6 +713,15 @@ arg_init(State, _Action, Arg = #{type := {string, _}, default := X}) -> arg_init(State, _Action, Arg = #{type := Unsupported}) -> {State, Arg#{val => {error, Unsupported}}}. +parse_arg(State, _Action, #{type := TypeInfo}, Unparsed) -> + case TypeInfo of + {T, F, _Validation} when is_function(F) -> + parse_with_fun(T, F, Unparsed, State); + {T, Regex, _Validation} when is_list(Regex); is_binary(Regex) -> + F = fun(String, St) -> parse_regex(Regex, String, St) end, + parse_with_fun(T, F, Unparsed, State) + end. + replace([H|T], H, R) -> [R|T]; replace([H|T], S, R) -> [H|replace(T, S, R)]. From 452a4ae87c664bb67d40f26e8d304f5f63c3a945 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Mon, 13 Jan 2025 12:47:53 -0500 Subject: [PATCH 04/27] fix small bugs, track node name over menus --- cli/revault_cli/src/revault_curses.escript | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index e2d6341..cd2e283 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -186,7 +186,7 @@ check_dirs(#{local_node := Node}, Dirs) -> Others -> {error, {unknown_dirs, Others}} end catch - _E:_R -> [] + E:R -> {error, {E,R}} end. check_peer(State = #{local_node := Node}, Peer) -> @@ -208,7 +208,7 @@ check_peer(State = #{local_node := Node}, Peer) -> {error, {unknown_peer, Peer, Peers}} end catch - _E:_R -> [] + E:R -> {error, {E,R}} end. check_ignore(_, _) -> @@ -698,7 +698,8 @@ arg_output(State, Action, [#{type := Unsupported}=Arg|Args], Acc) -> arg_output(State, Action, [Arg#{line => Line}|Args], Acc). arg_init(State, _Action, Arg = #{type := {node, _, _}, default := Default}) -> - {State#{local_node => Default}, Arg#{val => Default}}; + Val = maps:get(local_node, State, Default), + {State#{local_node => Val}, Arg#{val => Val}}; arg_init(State, _Action, Arg = #{name := dirs, type := {list, _, _}, default := F}) -> Default = F(State), {State#{dir_list => Default}, Arg#{val => Default}}; From 47d6bae94b0cf01b02cb6da8a6b547fbffe9ea06 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Thu, 23 Jan 2025 08:35:36 -0500 Subject: [PATCH 05/27] implement exec section for list action --- cli/revault_cli/src/revault_curses.escript | 97 ++++++++++++++++++++-- 1 file changed, 88 insertions(+), 9 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index cd2e283..ad2c7db 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -255,9 +255,10 @@ state(Old) -> %% Refresh the layout to show proper coordinates Tmp1 = show_menu(Tmp0), Tmp2 = show_action(Tmp1), - Tmp3 = end_table(Tmp2), + Tmp3 = show_exec(Tmp2), + Tmp4 = end_table(Tmp3), cecho:refresh(), - Tmp3. + Tmp4. show_menu(State) -> @@ -300,11 +301,12 @@ show_menu(State) -> end, NewState. -show_action(State = #{mode := menu, - menu_coords := {_, End}}) -> +show_action(State = #{mode := Mode, + menu_coords := {_, End}}) when Mode == menu -> State#{action_coords => {End, End}}; -show_action(State = #{mode := action, menu := Action, - menu_coords := {_, {MenuY, MaxX}}}) -> +show_action(State = #{mode := Mode, menu := Action, + menu_coords := {_, {MenuY, MaxX}}}) when Mode == action; + Mode == exec -> MinY = MenuY, %% TODO: truncate lines that are too long {ArgState, Args} = arg_output(State, Action, @@ -337,11 +339,40 @@ show_action(State = #{mode := action, menu := Action, mv(First) end end, - ArgState#{action_coords => {{MinY,0}, {MaxY,MaxX}}, + ExtraLines = case Mode of + action -> + 0; % this is the last section + _ -> + %% terminate table section + BottomRow = ["╟", lists:duplicate(MaxX-1, "─"), "╢"], + str(MaxY+1, 0, BottomRow), + 1 + end, + ArgState#{action_coords => {{MinY,0}, {MaxY+ExtraLines,MaxX}}, action_args => Ranges, action_init_pos => {MinY, 2}}. -end_table(State=#{action_coords := {_, {Y,X}}}) -> +show_exec(State=#{mode := Mode, + action_coords := {_, {Y,X}}}) when Mode =/= exec -> + State#{exec_coords => {{Y,0},{Y,X}}}; +show_exec(State=#{mode := exec, + menu := Action, + action_coords := {_, {ActionY,MaxX}}}) -> + MinY = ActionY, + %% expect line-based output in a list + MaxLines = 15, + MaxCols = MaxX-4, + {ExecState, Strs} = render_exec(Action, MaxLines, MaxCols, State), + MaxY = MinY + MaxLines, + LinesY = lists:foldl(fun(Line, Y) -> + str(Y+1, 0, ["║ ", string:pad(Line, MaxX-3), " ║"]), + Y+1 + end, MinY, Strs), + [str(LineY, 0, ["║", lists:duplicate(MaxX-1, " "), "║"]) + || LineY <- lists:seq(LinesY+1, MaxY)], + ExecState#{exec_coords => {{MinY,0},{MaxY,MaxX}}}. + +end_table(State=#{exec_coords := {_, {Y,X}}}) -> str(Y+1, 0, ["╚", lists:duplicate(X-1, "═") ,"╝"]), str(Y+2, 0, " ╰─ "), State#{status_coords => {{Y+1,0}, {Y+2,5}}, @@ -370,6 +401,13 @@ loop(OldState) -> {input, Input} -> {ok, NewState} = handle_action({input, Input}, Action, State), loop(NewState) + end; + exec -> + #{menu := Action} = State, + receive + {input, Input} -> + {ok, NewState} = handle_exec({input, Input}, Action, State), + loop(NewState) end end. @@ -577,7 +615,8 @@ handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> {_, []} -> %% TODO: change state to execution State = show_status(TmpState, "ok."), - {ok, State}; + {ok, State#{mode => exec, + exec_args => Args}}; {_, [{#{line := {Label, _}}, Reason}|_]} -> State = show_status( TmpState, @@ -600,6 +639,26 @@ handle_action({input, UnknownChar}, Action, TmpState) -> ), {ok, State}. +handle_exec({input, ?ceKEY_DOWN}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {Y+1, X}}}}; +handle_exec({input, ?ceKEY_UP}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {max(0,Y-1), X}}}}; +handle_exec({input, ?ceKEY_RIGHT}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {Y, X+1}}}}; +handle_exec({input, ?ceKEY_LEFT}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {Y, max(0,X-1)}}}}; +%% TODO: pgup, pgdwn +handle_exec({input, UnknownChar}, Action, TmpState) -> + State = show_status( + TmpState, + io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) + ), + {ok, State}. + mv_by({OffsetY, OffsetX}) -> {CY, CX} = cecho:getyx(), cecho:move(CY+OffsetY, CX+OffsetX). @@ -723,6 +782,26 @@ parse_arg(State, _Action, #{type := TypeInfo}, Unparsed) -> parse_with_fun(T, F, Unparsed, State) end. +render_exec(list, MaxLines, MaxCols, State) -> + {ok, Path, Config, {OffY,OffX}} = case State of + #{exec_state := #{path := P, config := C, offset := Off}} -> + {ok, P, C, Off}; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {ok, P, C} = rpc:call(Node, maestro_loader, current, []), + {ok, P, C, {0,0}} + end, + Brk = io_lib:format("~n", []), + Str = io_lib:format("Config parsed from ~ts:~n~p~n", [Path, Config]), + %% Fit lines and the whole thing in a "box" + Lines = string:lexemes(Str, Brk), + Truncated = [string:slice(S, OffX, MaxCols) + || S <- lists:sublist(Lines, OffY+1, MaxLines)], + {State#{exec_state => #{path => Path, config => Config, offset => {OffY,OffX}}}, + Truncated}; +render_exec(Action, _MaxLines, _MaxCols, State) -> + {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. + replace([H|T], H, R) -> [R|T]; replace([H|T], S, R) -> [H|replace(T, S, R)]. From f822b85dd38c3162099b1492027527678e2875e2 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 25 Jan 2025 14:59:25 -0500 Subject: [PATCH 06/27] small patches --- cli/revault_cli/src/revault_curses.escript | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index ad2c7db..382a594 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -14,6 +14,7 @@ (not(X < 32) andalso not(X >= 127 andalso X < 160))). +-define(EXEC_LINES, 15). -define(MAX_VALIDATION_DELAY, 150). % longest time to validate input, in ms -define(LOG(X), ok). @@ -360,7 +361,7 @@ show_exec(State=#{mode := exec, action_coords := {_, {ActionY,MaxX}}}) -> MinY = ActionY, %% expect line-based output in a list - MaxLines = 15, + MaxLines = ?EXEC_LINES, MaxCols = MaxX-4, {ExecState, Strs} = render_exec(Action, MaxLines, MaxCols, State), MaxY = MinY + MaxLines, @@ -639,6 +640,11 @@ handle_action({input, UnknownChar}, Action, TmpState) -> ), {ok, State}. +handle_exec({input, ?ceKEY_ESC}, _Action, TmpState) -> + %% clear up the arg list + State = maps:without([exec_state], TmpState#{mode => action}), + cecho:erase(), + {ok, State}; handle_exec({input, ?ceKEY_DOWN}, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), {ok, State#{exec_state => ES#{offset => {Y+1, X}}}}; @@ -651,7 +657,15 @@ handle_exec({input, ?ceKEY_RIGHT}, list, State = #{exec_state:=ES}) -> handle_exec({input, ?ceKEY_LEFT}, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), {ok, State#{exec_state => ES#{offset => {Y, max(0,X-1)}}}}; -%% TODO: pgup, pgdwn +handle_exec({input, ?ceKEY_PGDOWN}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + Shift = ?EXEC_LINES-1, + {ok, State#{exec_state => ES#{offset => {Y+Shift, X}}}}; +handle_exec({input, ?ceKEY_PGUP}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + Shift = ?EXEC_LINES-1, + {ok, State#{exec_state => ES#{offset => {max(0,Y-Shift), X}}}}; +%% TODO: ctrlA, ctrlE handle_exec({input, UnknownChar}, Action, TmpState) -> State = show_status( TmpState, From 372c14dc0c41fad2d1bef20c3cdb3dcf12f68f4d Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Thu, 20 Feb 2025 08:57:36 -0500 Subject: [PATCH 07/27] add scan and sync menus --- cli/revault_cli/src/revault_curses.escript | 232 +++++++++++++++++++++ 1 file changed, 232 insertions(+) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 382a594..8f02014 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -408,6 +408,9 @@ loop(OldState) -> receive {input, Input} -> {ok, NewState} = handle_exec({input, Input}, Action, State), + loop(NewState); + {revault, Action, _} = Event -> + {ok, NewState} = handle_exec(Event, Action, State), loop(NewState) end end. @@ -645,6 +648,7 @@ handle_exec({input, ?ceKEY_ESC}, _Action, TmpState) -> State = maps:without([exec_state], TmpState#{mode => action}), cecho:erase(), {ok, State}; +%% List exec handle_exec({input, ?ceKEY_DOWN}, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), {ok, State#{exec_state => ES#{offset => {Y+1, X}}}}; @@ -666,11 +670,84 @@ handle_exec({input, ?ceKEY_PGUP}, list, State = #{exec_state:=ES}) -> Shift = ?EXEC_LINES-1, {ok, State#{exec_state => ES#{offset => {max(0,Y-Shift), X}}}}; %% TODO: ctrlA, ctrlE +%% Scan exec +handle_exec({revault, scan, done}, scan, State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, scan, {Dir, Status}}, scan, State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; +handle_exec({input, ?KEY_ENTER}, scan, State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {revault, scan, done}, + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; +%% Sync exec +handle_exec({revault, sync, done}, sync, State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, sync, {Dir, Status}}, sync, State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; +handle_exec({input, ?KEY_ENTER}, sync, State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {revault, scan, done}, + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; +%% Generic exec handle_exec({input, UnknownChar}, Action, TmpState) -> State = show_status( TmpState, io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) ), + {ok, State}; +handle_exec({revault, EventAct, Event}, Act, TmpState) -> + State = show_status( + TmpState, + io_lib:format("Got unexpected ~p event in ~p: ~p", [EventAct, Act, Event]) + ), + {ok, State}; +handle_exec(Msg, Action, TmpState) -> + State = show_status( + TmpState, + io_lib:format("Got unexpected message in ~p: ~p", [Action, Msg]) + ), {ok, State}. mv_by({OffsetY, OffsetX}) -> @@ -813,6 +890,62 @@ render_exec(list, MaxLines, MaxCols, State) -> || S <- lists:sublist(Lines, OffY+1, MaxLines)], {State#{exec_state => #{path => Path, config => Config, offset => {OffY,OffX}}}, Truncated}; +render_exec(scan, MaxLines, MaxCols, State) -> + {ok, Pid, Statuses} = case State of + #{exec_state := #{worker := P, dirs := DirsStatuses}} -> + {ok, P, DirsStatuses}; + #{exec_args := Args} -> + self() ! init_scan, + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {scan, Node, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + {ok, P, DirStatuses} + end, + LStatuses = lists:sort(maps:to_list(Statuses)), + %% TODO: support scrolling if you have more Dirs than MaxLines or + %% dirs that are too long. + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + true = MaxLines >= length(LStatuses), + true = MaxCols >= LongestDir + 4, % 4 chars for the status display room + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> "??"; + ok -> "ok"; + _ -> "!!" + end] || {Dir, Status} <- LStatuses], + {State#{exec_state => #{worker => Pid, dirs => Statuses}}, Strs}; +render_exec(sync, MaxLines, MaxCols, State) -> + {ok, Pid, Peer, Statuses} = case State of + #{exec_state := #{worker := W, peer := P, dirs := DirsStatuses}} -> + {ok, W, P, DirsStatuses}; + #{exec_args := Args} -> + self() ! init_scan, + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + W = start_worker(self(), {sync, Node, P, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + {ok, W, P, DirStatuses} + end, + LStatuses = lists:sort(maps:to_list(Statuses)), + %% TODO: support scrolling if you have more Dirs than MaxLines or + %% dirs that are too long. + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + true = MaxLines >= length(LStatuses), + true = MaxCols >= LongestDir + 4, % 4 chars for the status display room + Header = [string:pad("DIR", LongestDir+1, trailing, " "), " SCAN SYNC"], + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> " ??"; + scanned -> " ok ??"; + synced -> " ok ok"; + _ -> " !! !!" + end] || {Dir, Status} <- LStatuses], + {State#{exec_state => #{worker => Pid, peer => Peer, dirs => Statuses}}, + [Header | Strs]}; render_exec(Action, _MaxLines, _MaxCols, State) -> {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. @@ -877,4 +1010,103 @@ config(Node) -> {ok, Path, Config} = rpc:call(Node, maestro_loader, current, []), {config, Path, Config}. +start_worker(ReplyTo, Call) -> + Parent = self(), + spawn_link(fun() -> worker(Parent, ReplyTo, Call) end). + +worker(Parent, ReplyTo, {scan, Node, Dirs}) -> + worker_scan(Parent, ReplyTo, Node, Dirs); +worker(Parent, ReplyTo, {sync, Node, Peer, Dirs}) -> + worker_sync(Parent, ReplyTo, Node, Peer, Dirs). + +worker_scan(Parent, ReplyTo, Node, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so scan them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_dirmon_event, force_scan, [Dir, infinity], + Dir, Ids) + end, erpc:reqids_new(), Dirs), + worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds). + +worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, scan, done}, + exit(normal); + no_response -> + worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds); + {{response, Res}, Dir, NewIds} -> + ReplyTo ! {revault, scan, {Dir, Res}}, + worker_scan_loop(Parent, ReplyTo, Node, Dirs, NewIds) + end + end. + +worker_sync(Parent, ReplyTo, Node, Peer, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so sync them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_dirmon_event, force_scan, [Dir, infinity], + {scan, Dir}, Ids) + end, erpc:reqids_new(), Dirs), + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds). + +worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, sync, done}, + exit(normal); + no_response -> + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); + {{response, Res}, {scan, Dir}, TmpIds} -> + Status = case Res of + ok -> scanned; + Other -> Other + end, + ReplyTo ! {revault, sync, {Dir, Status}}, + NewIds = erpc:send_request( + Node, + revault_fsm, sync, [Dir, Peer], + {sync, Dir}, + TmpIds + ), + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds); + {{response, Res}, {sync, Dir}, NewIds} -> + Status = case Res of + ok -> synced; + Other -> Other + end, + ReplyTo ! {revault, sync, {Dir, Status}}, + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) + end + end. From ac4222d0e21082c3a8b3411c4c36fe28aaf499ae Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Fri, 7 Mar 2025 17:53:27 -0500 Subject: [PATCH 08/27] implement status in ncurses --- cli/revault_cli/src/revault_curses.escript | 54 +++++++++++++++++++++- rebar.config | 3 ++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 8f02014..e7d5a7f 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -730,6 +730,17 @@ handle_exec({input, ?KEY_ENTER}, sync, State) -> self() ! {input, ?ceKEY_ESC}, self() ! {input, ?KEY_ENTER}, {ok, State}; +%% Status +handle_exec({revault, status, done}, status, State) -> + {ok, State}; +handle_exec({revault, status, {ok, Val}}, status, State=#{exec_state:=ES}) -> + {ok, State#{exec_state => ES#{status => Val}}}; +handle_exec({input, ?KEY_ENTER}, status, State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {revault, status, done}, + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; %% Generic exec handle_exec({input, UnknownChar}, Action, TmpState) -> State = show_status( @@ -946,6 +957,19 @@ render_exec(sync, MaxLines, MaxCols, State) -> end] || {Dir, Status} <- LStatuses], {State#{exec_state => #{worker => Pid, peer => Peer, dirs => Statuses}}, [Header | Strs]}; +render_exec(status, _MaxLines, _MaxCols, State) -> + {ok, Pid, Status} = case State of + #{exec_state := #{worker := P, status := V}} -> + {ok, P, V}; + #{exec_args := Args} -> + self() ! init_scan, + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {status, Node}), + {ok, P, undefined} + end, + Strs = [io_lib:format("~p",[Status])], + {State#{exec_state => #{worker => Pid, status => Status}}, Strs}; render_exec(Action, _MaxLines, _MaxCols, State) -> {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. @@ -1017,7 +1041,9 @@ start_worker(ReplyTo, Call) -> worker(Parent, ReplyTo, {scan, Node, Dirs}) -> worker_scan(Parent, ReplyTo, Node, Dirs); worker(Parent, ReplyTo, {sync, Node, Peer, Dirs}) -> - worker_sync(Parent, ReplyTo, Node, Peer, Dirs). + worker_sync(Parent, ReplyTo, Node, Peer, Dirs); +worker(Parent, ReplyTo, {status, Node}) -> + worker_status(Parent, ReplyTo, Node). worker_scan(Parent, ReplyTo, Node, Dirs) -> %% assume we are connected from arg validation time. @@ -1110,3 +1136,29 @@ worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> end end. +worker_status(Parent, ReplyTo, Node) -> + process_flag(trap_exit, true), + ReqIds = erpc:send_request(Node, + maestro_loader, status, [], + status, erpc:reqids_new()), + worker_status_loop(Parent, ReplyTo,ReqIds). + +worker_status_loop(Parent, ReplyTo, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + exit(Reason); + stop -> + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, status, done}, + exit(normal); + no_response -> + worker_status_loop(Parent, ReplyTo, ReqIds); + {{response, Res}, status, NewIds} -> + ReplyTo ! {revault, status, {ok, Res}}, + worker_status_loop(Parent, ReplyTo, NewIds) + end + end. diff --git a/rebar.config b/rebar.config index 2f662c4..7f44484 100644 --- a/rebar.config +++ b/rebar.config @@ -74,6 +74,9 @@ {system_libs, false} ]} ]}, + {ncurses, [ + {deps, [{cecho, {git, "https://github.com/mazenharake/cecho.git", {branch, "master"}}}]} + ]}, {debug, [ %% generate debug traces in gen_* processes {erl_opts, [{d, 'TEST'}]} From c88d4c9f0efc7ccf8f8b106f8aec0a008808750a Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Thu, 13 Mar 2025 08:55:27 -0400 Subject: [PATCH 09/27] Fix deletion conflict issues Because of how conflicts are transmitted file by file, there's always potential for a delete conflict to happen, where 2 or more hosts have concurrently deleted a file without synchronization. We could _merge_ both deletes and bump the version, but this could run the risk of ignoring trailing conflicts in a transfer. Instead, we go explicit by adding deletion conflict messages to the protocol, and creating empty conflict files when such conflicts are detected. The user then has to delete the conflict file to resolve everything. --- apps/revault/src/revault_data_wrapper.erl | 6 +- apps/revault/src/revault_data_wrapper.hrl | 2 +- apps/revault/src/revault_dirmon_tracker.erl | 8 +++ apps/revault/src/revault_disterl.erl | 2 + apps/revault/src/revault_fsm.erl | 40 ++++++++++++- apps/revault/src/revault_tcp.erl | 2 + apps/revault/src/revault_tls.erl | 2 + apps/revault/test/revault_fsm_SUITE.erl | 65 +++++++++++++++++++++ 8 files changed, 124 insertions(+), 3 deletions(-) diff --git a/apps/revault/src/revault_data_wrapper.erl b/apps/revault/src/revault_data_wrapper.erl index 5533853..9d0d5cc 100644 --- a/apps/revault/src/revault_data_wrapper.erl +++ b/apps/revault/src/revault_data_wrapper.erl @@ -11,7 +11,8 @@ -module(revault_data_wrapper). -export([peer/1, peer/2, new/0, ask/0, ok/0, error/1, fork/2]). -export([manifest/0, manifest/1, - send_file/4, send_multipart_file/6, send_deleted/2, + send_file/4, send_multipart_file/6, + send_deleted/2, send_conflict_deleted/3, send_conflict_file/5, send_conflict_multipart_file/7, fetch_file/1, sync_complete/0]). @@ -73,6 +74,9 @@ send_multipart_file(Path, Vsn, Hash, M, N, Bin) when M >= 1, M =< N -> send_deleted(Path, Vsn) -> {deleted_file, ?VSN, Path, {Vsn, deleted}}. +send_conflict_deleted(WorkPath, ConflictsLeft, Meta) -> + {conflict_file, ?VSN, WorkPath, deleted, ConflictsLeft, Meta}. + send_conflict_file(WorkPath, Path, ConflictsLeft, Meta, Bin) -> {conflict_file, ?VSN, WorkPath, Path, ConflictsLeft, Meta, Bin}. diff --git a/apps/revault/src/revault_data_wrapper.hrl b/apps/revault/src/revault_data_wrapper.hrl index 040a294..5df7eb3 100644 --- a/apps/revault/src/revault_data_wrapper.hrl +++ b/apps/revault/src/revault_data_wrapper.hrl @@ -1,4 +1,4 @@ % VSN 1: initial protocol % VSN 2: adds multipart file transfers % TODO: add test about protocol compatibility --define(VSN, 2). +-define(VSN, 3). diff --git a/apps/revault/src/revault_dirmon_tracker.erl b/apps/revault/src/revault_dirmon_tracker.erl index 9b4f3ca..449f0f5 100644 --- a/apps/revault/src/revault_dirmon_tracker.erl +++ b/apps/revault/src/revault_dirmon_tracker.erl @@ -151,6 +151,14 @@ handle_call({conflict, Work, {NewStamp, deleted}}, _From, %% but note the deletion stamp as part of the conflict. CStamp = conflict_stamp(Id, Stamp, NewStamp), {CStamp, {conflict, ConflictHashes, WorkingHash}}; + #{Work := {Stamp, deleted}} -> + %% This is a special case similar to having both files diverging + %% in stamps but having the same "hash" or value by virtue of being + %% deleted. Create an empty conflict file, assume further files might + %% come in as part of the sync or that this will properly carry + %% the state moving forward. + CStamp = conflict_stamp(Id, Stamp, NewStamp), + {CStamp, {conflict, [], deleted}}; #{Work := {Stamp, WorkingHash}} -> %% No conflict, create it ConflictingWork = revault_conflict_file:conflicting(Work, WorkingHash), diff --git a/apps/revault/src/revault_disterl.erl b/apps/revault/src/revault_disterl.erl index ba3656f..e4625db 100644 --- a/apps/revault/src/revault_disterl.erl +++ b/apps/revault/src/revault_disterl.erl @@ -83,6 +83,8 @@ unpack({file, ?VSN, Path, Meta, PartNum, PartTotal, Bin}) -> {file, Path, Meta, unpack({fetch, ?VSN, Path}) -> {fetch, Path}; unpack({sync_complete, ?VSN}) -> sync_complete; unpack({deleted_file, ?VSN, Path, Meta}) -> {deleted_file, Path, Meta}; +unpack({conflict_file, ?VSN, WorkPath, deleted, Count, Meta}) -> + {conflict_file, WorkPath, deleted, Count, Meta}; unpack({conflict_file, ?VSN, WorkPath, Path, Count, Meta, Bin}) -> {conflict_file, WorkPath, Path, Count, Meta, Bin}; unpack({conflict_multipart_file, ?VSN, WorkPath, Path, Count, Meta, PartNum, PartTotal, Bin}) -> diff --git a/apps/revault/src/revault_fsm.erl b/apps/revault/src/revault_fsm.erl index 7efe9d6..3dc2a93 100644 --- a/apps/revault/src/revault_fsm.erl +++ b/apps/revault/src/revault_fsm.erl @@ -637,6 +637,25 @@ client_sync_files(info, {revault, _Marker, {deleted_file, F, Meta}}, Data) -> NewQ = send_next_scheduled(Q), NewAcc = Acc -- [F], {keep_state, Data#data{scan=true, sub=S#client_sync{queue=NewQ, acc=NewAcc}}}; +client_sync_files(info, {revault, _Marker, {conflict_file, WorkF, deleted, CountLeft, Meta}}, Data) -> + #data{name=Name, sub=S=#client_sync{queue=Q, acc=Acc}} = Data, + ?with_span( + <<"conflict">>, + #{attributes => [{<<"path">>, WorkF}, {<<"meta">>, ?str(Meta)}, + {<<"count">>, CountLeft} | ?attrs(Data)]}, + fun(_SpanCtx) -> + %% TODO: handle the file being corrupted vs its own hash + revault_dirmon_tracker:conflict(Name, WorkF, Meta) + end + ), + case CountLeft =:= 0 andalso Acc -- [WorkF] of + false -> + %% more of the same conflict file to come + {keep_state, Data#data{scan=true}}; + NewAcc -> + NewQ = send_next_scheduled(Q), + {keep_state, Data#data{scan=true, sub=S#client_sync{queue=NewQ, acc=NewAcc}}} + end; client_sync_files(info, {revault, _Marker, {conflict_file, WorkF, F, CountLeft, Meta, Bin}}, Data) -> #data{name=Name, sub=S=#client_sync{queue=Q, acc=Acc}} = Data, ?with_span( @@ -854,6 +873,16 @@ server_sync_files(info, {revault, _Marker, {deleted_file, F, Meta}}, | ?attrs(Data)]}, fun(_SpanCtx) -> handle_delete_sync(Name, Id, F, Meta) end), {keep_state, Data#data{scan=true}}; +server_sync_files(info, {revault, _M, {conflict_file, WorkF, deleted, CountLeft, Meta}}, Data) -> + ?with_span( + <<"conflict">>, + #{attributes => [{<<"path">>, WorkF}, {<<"meta">>, ?str(Meta)}, + {<<"count">>, CountLeft} | ?attrs(Data)]}, + fun(_SpanCtx) -> + revault_dirmon_tracker:conflict(Data#data.name, WorkF, Meta) + end + ), + {keep_state, Data#data{scan=true}}; server_sync_files(info, {revault, _M, {conflict_file, WorkF, F, CountLeft, Meta, Bin}}, Data) -> %% TODO: handle the file being corrupted vs its own hash ?with_span( @@ -1202,6 +1231,11 @@ file_transfer_schedule(Name, Path, File) -> case revault_dirmon_tracker:file(Name, File) of {Vsn, deleted} -> [{deleted, File, Vsn}]; + {Vsn, {conflict, [], deleted}} -> + %% Special deletion case where clashing deleted files + %% exist; there's no hash to send here, and no FHash; + %% explicitly call it as deleted. + [{conflict_file, File, deleted, 0, {Vsn, deleted}}]; {Vsn, {conflict, Hashes, _}} -> {List, _} = lists:foldl( fun(Hash, {Acc, Ct}) -> @@ -1231,6 +1265,11 @@ wrap(_Path, {deleted, File, Vsn}) -> ?set_attribute(<<"path">>, File), ?set_attribute(<<"transfer_type">>, <<"deleted">>), revault_data_wrapper:send_deleted(File, Vsn); +wrap(_Path, {conflict_file, File, deleted, Ct, Meta}) -> + ?set_attribute(<<"path">>, deleted), + ?set_attribute(<<"transfer_type">>, <<"conflict_file">>), + ?set_attribute(<<"conflict.ct">>, Ct), + revault_data_wrapper:send_conflict_deleted(File, Ct, Meta); wrap(Path, {conflict_file, File, FHash, Ct, Meta}) -> ?set_attribute(<<"path">>, FHash), ?set_attribute(<<"transfer_type">>, <<"conflict_file">>), @@ -1303,4 +1342,3 @@ pid_attrs() -> proplists:get_value(minor_gcs, proplists:get_value(garbage_collection, PidInfo))} ]. - diff --git a/apps/revault/src/revault_tcp.erl b/apps/revault/src/revault_tcp.erl index ad47da4..a86c49c 100644 --- a/apps/revault/src/revault_tcp.erl +++ b/apps/revault/src/revault_tcp.erl @@ -134,6 +134,8 @@ unpack({file, ?VSN, Path, Meta, PartNum, PartTotal, Bin}) -> {file, Path, Meta, unpack({fetch, ?VSN, Path}) -> {fetch, Path}; unpack({sync_complete, ?VSN}) -> sync_complete; unpack({deleted_file, ?VSN, Path, Meta}) -> {deleted_file, Path, Meta}; +unpack({conflict_file, ?VSN, WorkPath, deleted, Count, Meta}) -> + {conflict_file, WorkPath, deleted, Count, Meta}; unpack({conflict_file, ?VSN, WorkPath, Path, Count, Meta, Bin}) -> {conflict_file, WorkPath, Path, Count, Meta, Bin}; unpack({conflict_multipart_file, ?VSN, WorkPath, Path, Count, Meta, PartNum, PartTotal, Bin}) -> diff --git a/apps/revault/src/revault_tls.erl b/apps/revault/src/revault_tls.erl index 6db9f52..b2f9402 100644 --- a/apps/revault/src/revault_tls.erl +++ b/apps/revault/src/revault_tls.erl @@ -138,6 +138,8 @@ unpack({file, ?VSN, Path, Meta, PartNum, PartTotal, Bin}) -> {file, Path, Meta, unpack({fetch, ?VSN, Path}) -> {fetch, Path}; unpack({sync_complete, ?VSN}) -> sync_complete; unpack({deleted_file, ?VSN, Path, Meta}) -> {deleted_file, Path, Meta}; +unpack({conflict_file, ?VSN, WorkPath, deleted, Count, Meta}) -> + {conflict_file, WorkPath, deleted, Count, Meta}; unpack({conflict_file, ?VSN, WorkPath, Path, Count, Meta, Bin}) -> {conflict_file, WorkPath, Path, Count, Meta, Bin}; unpack({conflict_multipart_file, ?VSN, WorkPath, Path, Count, Meta, PartNum, PartTotal, Bin}) -> diff --git a/apps/revault/test/revault_fsm_SUITE.erl b/apps/revault/test/revault_fsm_SUITE.erl index babbcbc..ece573a 100644 --- a/apps/revault/test/revault_fsm_SUITE.erl +++ b/apps/revault/test/revault_fsm_SUITE.erl @@ -22,6 +22,7 @@ groups() -> fork_server_save, seed_fork, basic_sync, delete_sync, too_many_clients, overwrite_sync_clash, conflict_sync, + delete_sync_conflict, prevent_server_clash, multipart, double_conflict]}]. @@ -714,6 +715,70 @@ conflict_sync(Config) -> ?assertEqual({ok, <<"sh2">>}, file:read_file(filename:join([ClientPath2, "shared.1C56416E"]))), ok. +delete_sync_conflict() -> + [{doc, "A deletion conflict can be sync'd to a third party"}, + {timetrap, timer:seconds(5)}]. +delete_sync_conflict(Config) -> + Client = ?config(name, Config), + Server=?config(server, Config), + Remote = (?config(peer, Config))(Server), + ClientPath = ?config(path, Config), + ServerPath = ?config(server_path, Config), + {ok, _ServId1} = revault_fsm:id(Server), + {ok, _} = revault_fsm_sup:start_fsm( + ?config(db_dir, Config), + Client, + ClientPath, + ?config(ignore, Config), + ?config(interval, Config), + (?config(callback, Config))(Client) + ), + ok = revault_fsm:client(Client), + {ok, _ClientId} = revault_fsm:id(Client, Remote), + %% Set up a second client; because of how config works in the test, it needs + Client2 = Client ++ "_2", + Priv = ?config(priv_dir, Config), + DbDir2 = filename:join([Priv, "db_2"]), + ClientPath2 = filename:join([Priv, "data", "client_2"]), + filelib:ensure_dir(filename:join([DbDir2, "fakefile"])), + filelib:ensure_dir(filename:join([ClientPath2, "fakefile"])), + {ok, _} = revault_fsm_sup:start_fsm(DbDir2, Client2, ClientPath2, + ?config(ignore, Config), ?config(interval, Config), + (?config(callback, Config))(Client2)), + ok = revault_fsm:client(Client2), + ?assertMatch({ok, _}, revault_fsm:id(Client2, Remote)), + %% now in initialized mode + %% Write files + ok = file:write_file(filename:join([ServerPath, "shared"]), "sh1"), + ok = file:write_file(filename:join([ClientPath, "shared"]), "sh2"), + %% Track em + ok = revault_dirmon_event:force_scan(Client, 5000), + ok = revault_dirmon_event:force_scan(Server, 5000), + %% Delete em + ok = file:delete(filename:join([ServerPath, "shared"])), + ok = file:delete(filename:join([ClientPath, "shared"])), + %% Track the deletion + ok = revault_dirmon_event:force_scan(Client, 5000), + ok = revault_dirmon_event:force_scan(Server, 5000), + %% Sync em + ct:pal("SYNC", []), + ok = revault_fsm:sync(Client, Remote), + %% See the result + %% conflicting files are marked, with empty conflict files since nothing exists aside + %% from clashing deletions. + ?assertEqual({error, enoent}, file:read_file(filename:join([ServerPath, "shared"]))), + ?assertEqual({error, enoent}, file:read_file(filename:join([ClientPath, "shared"]))), + ?assertEqual({ok, <<"">>}, file:read_file(filename:join([ServerPath, "shared.conflict"])) ), + ?assertEqual({ok, <<"">>}, file:read_file(filename:join([ClientPath, "shared.conflict"])) ), + + %% Now when client 2 syncs, it gets the files and conflict files as well + ct:pal("SECOND SYNC", []), + ok = revault_fsm:sync(Client2, Remote), + %% conflicting files are marked, but working files aren't sync'd since they didn't exist here + ?assertEqual({error, enoent}, file:read_file(filename:join([ClientPath2, "shared"]))), + ?assertEqual({ok, <<"">>}, file:read_file(filename:join([ClientPath2, "shared.conflict"])) ), + ok. + prevent_server_clash() -> [{doc, "A client from a different server cannot connect to the wrong one " "as it is protected by a UUID."}, From 2f447d11a7a08f697260e959303bb372232e247d Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Thu, 13 Mar 2025 09:02:26 -0400 Subject: [PATCH 10/27] deal with deep deletion conflicts --- apps/revault/src/revault_dirmon_tracker.erl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/revault/src/revault_dirmon_tracker.erl b/apps/revault/src/revault_dirmon_tracker.erl index 449f0f5..6b36a95 100644 --- a/apps/revault/src/revault_dirmon_tracker.erl +++ b/apps/revault/src/revault_dirmon_tracker.erl @@ -382,8 +382,10 @@ conflict_marker(Dir, WorkingFile) -> write_conflict_marker(Dir, WorkingFile, {_, {conflict, Hashes, _}}) -> %% We don't care about the rename trick here, it's informational %% but all the critical data is tracked in the snapshot + F = conflict_marker(Dir, WorkingFile), + revault_file:ensure_dir(F), revault_file:write_file( - conflict_marker(Dir, WorkingFile), + F, lists:join($\n, [revault_conflict_file:hex(Hash) || Hash <- Hashes]) ). From 1cfb6545e4fd19ada452641591d3f35c2a50cf99 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Thu, 13 Mar 2025 20:34:12 -0400 Subject: [PATCH 11/27] minor fixes to parsing --- cli/revault_cli/src/revault_curses.escript | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index e7d5a7f..c31ee94 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -73,7 +73,7 @@ args() -> help => "List of directories to scan"}, #{name => peer, label => "Peer Node", type => {string, "^(?:\\s*)?(.+)(?:\\s*)?$", fun check_peer/2}, default => fun default_peers/1, - help => "List of peers"} + help => "Peer to sync against"} ], status => [ #{name => node, label => "Local Node", @@ -118,7 +118,7 @@ parse_list(String, State) -> try %% drop surrounding whitespace and split on commas S = string:trim(String, both), - L = re:split(S, "[\\s]*,[\\s]*", [{return, binary}]), + L = re:split(S, "[\\s]*,[\\s]*", [{return, binary}, unicode]), %% ignore empty results (<<>>) in returned value {ok, [B || B <- L], State} catch @@ -126,7 +126,7 @@ parse_list(String, State) -> end. parse_regex(Re, String, State) -> - case re:run(String, Re, [{capture, first, binary}]) of + case re:run(String, Re, [{capture, first, binary}, unicode]) of {match, [Str]} -> {ok, Str, State}; nomatch -> {error, invalid, State} end. @@ -159,7 +159,8 @@ default_peers(State = #{local_node := Node}) -> || Peer <- maps:keys(PeerMap), Dirs <- [maps:get(<<"sync">>, maps:get(Peer, PeerMap))], ordsets:is_subset(Needed, ordsets:from_list(Dirs))], - lists:join(", ", Peers) + %% Flatten into a string, since peer data espects a string. + unicode:characters_to_binary(lists:join(", ", Peers)) catch _E:_R -> [] end. From 0577e8b3a713f3f245ebb90e50a0d6bb9d4d8fd2 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Tue, 3 Jun 2025 21:48:51 -0400 Subject: [PATCH 12/27] add key generation to ncurses thing --- cli/revault_cli/src/revault_curses.escript | 102 ++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index c31ee94..c227ecd 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -742,6 +742,14 @@ handle_exec({input, ?KEY_ENTER}, status, State) -> self() ! {input, ?ceKEY_ESC}, self() ! {input, ?KEY_ENTER}, {ok, State}; +%% Generate-Keys +handle_exec({revault, 'generate-keys', {ok, Val}}, 'generate-keys', State=#{exec_state:=ES}) -> + {ok, State#{exec_state => ES#{status => Val}}}; +handle_exec({input, ?KEY_ENTER}, 'generate-keys', State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; %% Generic exec handle_exec({input, UnknownChar}, Action, TmpState) -> State = show_status( @@ -971,6 +979,21 @@ render_exec(status, _MaxLines, _MaxCols, State) -> end, Strs = [io_lib:format("~p",[Status])], {State#{exec_state => #{worker => Pid, status => Status}}, Strs}; +render_exec('generate-keys', MaxLines, MaxCols, State) -> + {ok, Pid, Exists} = case State of + #{exec_state := #{worker := P, status := Status}} -> + %% Do wrapping of the status line + {ok, P, Status}; + #{exec_args := Args} -> + self() ! generate_keys, + {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), + {value, #{val := File}} = lists:search(fun(#{name := N}) -> N == certname end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {generate_keys, Path, File}), + {ok, P, "generating keys..."} + end, + Strs = wrap(Exists, MaxCols, MaxLines), + {State#{exec_state => #{worker => Pid, status => Exists}}, Strs}; render_exec(Action, _MaxLines, _MaxCols, State) -> {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. @@ -1044,7 +1067,9 @@ worker(Parent, ReplyTo, {scan, Node, Dirs}) -> worker(Parent, ReplyTo, {sync, Node, Peer, Dirs}) -> worker_sync(Parent, ReplyTo, Node, Peer, Dirs); worker(Parent, ReplyTo, {status, Node}) -> - worker_status(Parent, ReplyTo, Node). + worker_status(Parent, ReplyTo, Node); +worker(Parent, ReplyTo, {generate_keys, Path, File}) -> + worker_generate_keys(Parent, ReplyTo, Path, File). worker_scan(Parent, ReplyTo, Node, Dirs) -> %% assume we are connected from arg validation time. @@ -1163,3 +1188,78 @@ worker_status_loop(Parent, ReplyTo, ReqIds) -> worker_status_loop(Parent, ReplyTo, NewIds) end end. + + +worker_generate_keys(Parent, ReplyTo, Path, File) -> + Res = make_selfsigned_cert(unicode:characters_to_list(Path), + unicode:characters_to_list(File)), + %% we actually don't have a loop, everything is local + %% and has already be run, so we just wait for a shutdown signal. + ReplyTo ! {revault, 'generate-keys', {ok, Res}}, + receive + {'EXIT', Parent, Reason} -> + exit(Reason); + stop -> + unlink(parent), + exit(shutdown) + end. + +%% Copied from revault_tls +make_selfsigned_cert(Dir, CertName) -> + check_openssl_vsn(), + + Key = filename:join(Dir, CertName ++ ".key"), + Cert = filename:join(Dir, CertName ++ ".crt"), + ok = filelib:ensure_dir(Cert), + Cmd = io_lib:format( + "openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes " + "-keyout '~ts' -out '~ts' -subj '/CN=example.org' " + "-addext 'subjectAltName=DNS:example.org,DNS:www.example.org,IP:127.0.0.1'", + [Key, Cert] % TODO: escape quotes + ), + os:cmd(Cmd). + +check_openssl_vsn() -> + Vsn = os:cmd("openssl version"), + VsnMatch = "(Open|Libre)SSL ([0-9]+)\\.([0-9]+)\\.([0-9]+)", + case re:run(Vsn, VsnMatch, [{capture, all_but_first, list}]) of + {match, [Type, Major, Minor, Patch]} -> + try + check_openssl_vsn(Type, list_to_integer(Major), + list_to_integer(Minor), + list_to_integer(Patch)) + catch + error:bad_vsn -> + error({openssl_vsn, Vsn}) + end; + _ -> + error({openssl_vsn, Vsn}) + end. + +%% Using OpenSSL >= 1.1.1 or LibreSSL >= 3.1.0 +check_openssl_vsn("Libre", A, B, _) when A > 3; + A == 3, B >= 1 -> + ok; +check_openssl_vsn("Open", A, B, C) when A > 1; + A == 1, B > 1; + A == 1, B == 1, C >= 1 -> + ok; +check_openssl_vsn(_, _, _, _) -> + error(bad_vsn). + +wrap(Str, Width, Lines) -> + wrap(Str, 0, Width, 0, Lines, [[]]). + +wrap(Str, Width, Width, Lines, Lines, Acc) -> + lists:reverse(Acc); +wrap(Str, Width, Width, Ln, Lines, [L|Acc]) -> + wrap(Str, 0, Width, Ln+1, Lines, [[],lists:reverse(L)|Acc]); +wrap(Str, W, Width, Ln, Lines, [L|Acc]) -> + case string:next_grapheme(Str) of + [Brk|Rest] when Brk == $\n; Brk == "\r\n" -> + wrap(Rest, 0, Width, Ln+1, Lines, [[], lists:reverse(L)|Acc]); + [C|Rest] -> + wrap(Rest, W+1, Width, Ln, Lines, [[C|L]|Acc]); + [] -> + lists:reverse([lists:reverse(L)|Acc]) + end. From 2add5bacc17e039872bacd2a80c1ce664e595565 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 7 Jun 2025 19:13:03 -0400 Subject: [PATCH 13/27] drop rpc calls, use erpc in curses --- cli/revault_cli/src/revault_curses.escript | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index c227ecd..5a958eb 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -899,7 +899,7 @@ render_exec(list, MaxLines, MaxCols, State) -> {ok, P, C, Off}; #{exec_args := Args} -> {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {ok, P, C} = rpc:call(Node, maestro_loader, current, []), + {ok, P, C} = erpc:call(Node, maestro_loader, current, []), {ok, P, C, {0,0}} end, Brk = io_lib:format("~n", []), @@ -1045,7 +1045,7 @@ timeout_call(Timeout, Fun) -> -spec revault_node(atom()) -> ok | {error, term()}. revault_node(Node) -> - try rpc:call(Node, maestro_loader, status, []) of + try erpc:call(Node, maestro_loader, status, []) of current -> ok; outdated -> ok; last_valid -> ok; @@ -1055,7 +1055,7 @@ revault_node(Node) -> end. config(Node) -> - {ok, Path, Config} = rpc:call(Node, maestro_loader, current, []), + {ok, Path, Config} = erpc:call(Node, maestro_loader, current, []), {config, Path, Config}. start_worker(ReplyTo, Call) -> From 7fe178708fbfbd7c4f02de660c3a22e1f1426ef3 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 28 Jun 2025 07:55:43 -0400 Subject: [PATCH 14/27] add SEED to curses cli --- cli/revault_cli/src/revault_curses.escript | 101 ++++++++++++++++++++- 1 file changed, 100 insertions(+), 1 deletion(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 5a958eb..d7b9e85 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -2,6 +2,7 @@ %%! -noinput -name ncurses_cli -setcookie revault_cookie -mode(compile). -include_lib("cecho/include/cecho.hrl"). +-export([main/1]). %% Name of the main running host, as specified in `config/vm.args' -define(DEFAULT_NODE, list_to_atom("revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@"))))). @@ -750,6 +751,37 @@ handle_exec({input, ?KEY_ENTER}, 'generate-keys', State) -> self() ! {input, ?ceKEY_ESC}, self() ! {input, ?KEY_ENTER}, {ok, State}; +%% Seed exec +handle_exec({revault, seed, done}, seed, State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, seed, {Dir, Status}}, seed, State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; +% do not endlessly re-seed? +% handle_exec({input, ?KEY_ENTER}, seed, State) -> +% %% Do a refresh by exiting the menu and re-entering again. Quite hacky. +% self() ! {revault, seed, done}, +% self() ! {input, ?ceKEY_ESC}, +% self() ! {input, ?KEY_ENTER}, +% {ok, State}; %% Generic exec handle_exec({input, UnknownChar}, Action, TmpState) -> State = show_status( @@ -994,6 +1026,34 @@ render_exec('generate-keys', MaxLines, MaxCols, State) -> end, Strs = wrap(Exists, MaxCols, MaxLines), {State#{exec_state => #{worker => Pid, status => Exists}}, Strs}; +render_exec(seed, MaxLines, MaxCols, State) -> + {ok, Pid, Statuses} = case State of + #{exec_state := #{worker := P, dirs := S}} -> + %% Do wrapping of the status line + {ok, P, S}; + #{exec_args := Args} -> + self() ! generate_keys, + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {seed, Node, Path, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + {ok, P, DirStatuses} + end, + LStatuses = lists:sort(maps:to_list(Statuses)), + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + true = MaxLines >= length(LStatuses), + true = MaxCols >= LongestDir + 4, % 4 chars for the status display room + Header = [string:pad("DIR", LongestDir+1, trailing, " "), " SEED"], + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> "??"; + ok -> "ok"; + _ -> "!!" + end] || {Dir, Status} <- LStatuses], + {State#{exec_state => #{worker => Pid, dirs => Statuses}}, + [Header | Strs]}; render_exec(Action, _MaxLines, _MaxCols, State) -> {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. @@ -1069,7 +1129,9 @@ worker(Parent, ReplyTo, {sync, Node, Peer, Dirs}) -> worker(Parent, ReplyTo, {status, Node}) -> worker_status(Parent, ReplyTo, Node); worker(Parent, ReplyTo, {generate_keys, Path, File}) -> - worker_generate_keys(Parent, ReplyTo, Path, File). + worker_generate_keys(Parent, ReplyTo, Path, File); +worker(Parent, ReplyTo, {seed, Node, Path, Dirs}) -> + worker_seed(Parent, ReplyTo, Node, Path, Dirs). worker_scan(Parent, ReplyTo, Node, Dirs) -> %% assume we are connected from arg validation time. @@ -1204,6 +1266,43 @@ worker_generate_keys(Parent, ReplyTo, Path, File) -> exit(shutdown) end. +worker_seed(Parent, ReplyTo, Node, Path, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so scan them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_fsm, seed_fork, [Dir, Path], + Dir, Ids) + end, erpc:reqids_new(), Dirs), + worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds). + +worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, seed, done}, + exit(normal); + no_response -> + worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds); + {{response, Res}, Dir, NewIds} -> + ReplyTo ! {revault, seed, {Dir, Res}}, + worker_seed_loop(Parent, ReplyTo, Node, Dirs, NewIds) + end + end. + %% Copied from revault_tls make_selfsigned_cert(Dir, CertName) -> check_openssl_vsn(), From 3c8cd556ec7fc61e47bf77250c5898bce21cbcb7 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 28 Jun 2025 16:40:43 -0400 Subject: [PATCH 15/27] add remote-seed operation to ncurses --- cli/revault_cli/src/revault_curses.escript | 103 +++++++++++++++++++-- 1 file changed, 93 insertions(+), 10 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index d7b9e85..0e59a44 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -782,6 +782,31 @@ handle_exec({revault, seed, {Dir, Status}}, seed, State=#{exec_state:=ES}) -> % self() ! {input, ?ceKEY_ESC}, % self() ! {input, ?KEY_ENTER}, % {ok, State}; +%% remote-seed exec +handle_exec({revault, 'remote-seed', done}, 'remote-seed', State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, 'remote-seed', {Dir, Status}}, 'remote-seed', State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + file:write_file("/tmp/dbg", io_lib:format("~p~n", [{Dir, Status}])), + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; %% Generic exec handle_exec({input, UnknownChar}, Action, TmpState) -> State = show_status( @@ -947,7 +972,6 @@ render_exec(scan, MaxLines, MaxCols, State) -> #{exec_state := #{worker := P, dirs := DirsStatuses}} -> {ok, P, DirsStatuses}; #{exec_args := Args} -> - self() ! init_scan, {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), %% TODO: replace with an alias @@ -973,7 +997,6 @@ render_exec(sync, MaxLines, MaxCols, State) -> #{exec_state := #{worker := W, peer := P, dirs := DirsStatuses}} -> {ok, W, P, DirsStatuses}; #{exec_args := Args} -> - self() ! init_scan, {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), @@ -1003,7 +1026,6 @@ render_exec(status, _MaxLines, _MaxCols, State) -> #{exec_state := #{worker := P, status := V}} -> {ok, P, V}; #{exec_args := Args} -> - self() ! init_scan, {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), %% TODO: replace with an alias P = start_worker(self(), {status, Node}), @@ -1017,7 +1039,6 @@ render_exec('generate-keys', MaxLines, MaxCols, State) -> %% Do wrapping of the status line {ok, P, Status}; #{exec_args := Args} -> - self() ! generate_keys, {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), {value, #{val := File}} = lists:search(fun(#{name := N}) -> N == certname end, Args), %% TODO: replace with an alias @@ -1032,7 +1053,6 @@ render_exec(seed, MaxLines, MaxCols, State) -> %% Do wrapping of the status line {ok, P, S}; #{exec_args := Args} -> - self() ! generate_keys, {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), @@ -1045,15 +1065,39 @@ render_exec(seed, MaxLines, MaxCols, State) -> LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), true = MaxLines >= length(LStatuses), true = MaxCols >= LongestDir + 4, % 4 chars for the status display room - Header = [string:pad("DIR", LongestDir+1, trailing, " "), " SEED"], Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", case Status of pending -> "??"; ok -> "ok"; _ -> "!!" end] || {Dir, Status} <- LStatuses], - {State#{exec_state => #{worker => Pid, dirs => Statuses}}, - [Header | Strs]}; + {State#{exec_state => #{worker => Pid, dirs => Statuses}}, Strs}; +render_exec('remote-seed', MaxLines, MaxCols, State) -> + {ok, Pid, Peer, Statuses} = case State of + #{exec_state := #{worker := W, peer := P, dirs := S}} -> + %% Do wrapping of the status line + {ok, W, P, S}; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + W = start_worker(self(), {'remote-seed', Node, P, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + {ok, W, P, DirStatuses} + end, + LStatuses = lists:sort(maps:to_list(Statuses)), + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + true = MaxLines >= length(LStatuses), + true = MaxCols >= LongestDir + 4, % 4 chars for the status display room + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> "??"; + {ok, _ITC} -> "ok"; + _ -> "!!" + end] || {Dir, Status} <- LStatuses], + {State#{exec_state => #{worker => Pid, peer => Peer, dirs => Statuses}}, + Strs}; render_exec(Action, _MaxLines, _MaxCols, State) -> {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. @@ -1131,7 +1175,9 @@ worker(Parent, ReplyTo, {status, Node}) -> worker(Parent, ReplyTo, {generate_keys, Path, File}) -> worker_generate_keys(Parent, ReplyTo, Path, File); worker(Parent, ReplyTo, {seed, Node, Path, Dirs}) -> - worker_seed(Parent, ReplyTo, Node, Path, Dirs). + worker_seed(Parent, ReplyTo, Node, Path, Dirs); +worker(Parent, ReplyTo, {'remote-seed', Node, Peer, Dirs}) -> + worker_remote_seed(Parent, ReplyTo, Node, Peer, Dirs). worker_scan(Parent, ReplyTo, Node, Dirs) -> %% assume we are connected from arg validation time. @@ -1303,6 +1349,43 @@ worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> end end. +worker_remote_seed(Parent, ReplyTo, Node, Peer, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so scan them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_fsm, id, [Dir, Peer], + Dir, Ids) + end, erpc:reqids_new(), Dirs), + worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds). + +worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, 'remote-seed', done}, + exit(normal); + no_response -> + worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); + {{response, Res}, Dir, NewIds} -> + ReplyTo ! {revault, 'remote-seed', {Dir, Res}}, + worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) + end + end. + %% Copied from revault_tls make_selfsigned_cert(Dir, CertName) -> check_openssl_vsn(), @@ -1349,7 +1432,7 @@ check_openssl_vsn(_, _, _, _) -> wrap(Str, Width, Lines) -> wrap(Str, 0, Width, 0, Lines, [[]]). -wrap(Str, Width, Width, Lines, Lines, Acc) -> +wrap(_Str, Width, Width, Lines, Lines, Acc) -> lists:reverse(Acc); wrap(Str, Width, Width, Ln, Lines, [L|Acc]) -> wrap(Str, 0, Width, Ln+1, Lines, [[],lists:reverse(L)|Acc]); From f703ebb0bfd55a31ebb2c940a82cdcbd82c510b5 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 28 Jun 2025 17:05:33 -0400 Subject: [PATCH 16/27] refactor exec_state initialization bits --- cli/revault_cli/src/revault_curses.escript | 194 ++++++++++++--------- 1 file changed, 107 insertions(+), 87 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 0e59a44..2f6a74f 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -951,34 +951,18 @@ parse_arg(State, _Action, #{type := TypeInfo}, Unparsed) -> end. render_exec(list, MaxLines, MaxCols, State) -> - {ok, Path, Config, {OffY,OffX}} = case State of - #{exec_state := #{path := P, config := C, offset := Off}} -> - {ok, P, C, Off}; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {ok, P, C} = erpc:call(Node, maestro_loader, current, []), - {ok, P, C, {0,0}} - end, + NewState = ensure_exec_state(list, State), + #{exec_state := #{path := Path, config := Config, offset := {OffY,OffX}}} = NewState, Brk = io_lib:format("~n", []), Str = io_lib:format("Config parsed from ~ts:~n~p~n", [Path, Config]), %% Fit lines and the whole thing in a "box" Lines = string:lexemes(Str, Brk), Truncated = [string:slice(S, OffX, MaxCols) || S <- lists:sublist(Lines, OffY+1, MaxLines)], - {State#{exec_state => #{path => Path, config => Config, offset => {OffY,OffX}}}, - Truncated}; + {NewState, Truncated}; render_exec(scan, MaxLines, MaxCols, State) -> - {ok, Pid, Statuses} = case State of - #{exec_state := #{worker := P, dirs := DirsStatuses}} -> - {ok, P, DirsStatuses}; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {scan, Node, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - {ok, P, DirStatuses} - end, + NewState = ensure_exec_state(scan, State), + #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), %% TODO: support scrolling if you have more Dirs than MaxLines or %% dirs that are too long. @@ -991,20 +975,10 @@ render_exec(scan, MaxLines, MaxCols, State) -> ok -> "ok"; _ -> "!!" end] || {Dir, Status} <- LStatuses], - {State#{exec_state => #{worker => Pid, dirs => Statuses}}, Strs}; + {NewState, Strs}; render_exec(sync, MaxLines, MaxCols, State) -> - {ok, Pid, Peer, Statuses} = case State of - #{exec_state := #{worker := W, peer := P, dirs := DirsStatuses}} -> - {ok, W, P, DirsStatuses}; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - W = start_worker(self(), {sync, Node, P, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - {ok, W, P, DirStatuses} - end, + NewState = ensure_exec_state(sync, State), + #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), %% TODO: support scrolling if you have more Dirs than MaxLines or %% dirs that are too long. @@ -1019,48 +993,20 @@ render_exec(sync, MaxLines, MaxCols, State) -> synced -> " ok ok"; _ -> " !! !!" end] || {Dir, Status} <- LStatuses], - {State#{exec_state => #{worker => Pid, peer => Peer, dirs => Statuses}}, - [Header | Strs]}; + {NewState, [Header | Strs]}; render_exec(status, _MaxLines, _MaxCols, State) -> - {ok, Pid, Status} = case State of - #{exec_state := #{worker := P, status := V}} -> - {ok, P, V}; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {status, Node}), - {ok, P, undefined} - end, + NewState = ensure_exec_state(status, State), + #{exec_state := #{status := Status}} = NewState, Strs = [io_lib:format("~p",[Status])], - {State#{exec_state => #{worker => Pid, status => Status}}, Strs}; + {NewState, Strs}; render_exec('generate-keys', MaxLines, MaxCols, State) -> - {ok, Pid, Exists} = case State of - #{exec_state := #{worker := P, status := Status}} -> - %% Do wrapping of the status line - {ok, P, Status}; - #{exec_args := Args} -> - {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), - {value, #{val := File}} = lists:search(fun(#{name := N}) -> N == certname end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {generate_keys, Path, File}), - {ok, P, "generating keys..."} - end, + NewState = ensure_exec_state('generate-keys', State), + #{exec_state := #{status := Exists}} = NewState, Strs = wrap(Exists, MaxCols, MaxLines), - {State#{exec_state => #{worker => Pid, status => Exists}}, Strs}; + {NewState, Strs}; render_exec(seed, MaxLines, MaxCols, State) -> - {ok, Pid, Statuses} = case State of - #{exec_state := #{worker := P, dirs := S}} -> - %% Do wrapping of the status line - {ok, P, S}; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {seed, Node, Path, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - {ok, P, DirStatuses} - end, + NewState = ensure_exec_state(seed, State), + #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), true = MaxLines >= length(LStatuses), @@ -1071,21 +1017,10 @@ render_exec(seed, MaxLines, MaxCols, State) -> ok -> "ok"; _ -> "!!" end] || {Dir, Status} <- LStatuses], - {State#{exec_state => #{worker => Pid, dirs => Statuses}}, Strs}; + {NewState, Strs}; render_exec('remote-seed', MaxLines, MaxCols, State) -> - {ok, Pid, Peer, Statuses} = case State of - #{exec_state := #{worker := W, peer := P, dirs := S}} -> - %% Do wrapping of the status line - {ok, W, P, S}; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - W = start_worker(self(), {'remote-seed', Node, P, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - {ok, W, P, DirStatuses} - end, + NewState = ensure_exec_state('remote-seed', State), + #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), true = MaxLines >= length(LStatuses), @@ -1096,11 +1031,96 @@ render_exec('remote-seed', MaxLines, MaxCols, State) -> {ok, _ITC} -> "ok"; _ -> "!!" end] || {Dir, Status} <- LStatuses], - {State#{exec_state => #{worker => Pid, peer => Peer, dirs => Statuses}}, - Strs}; + {NewState, Strs}; render_exec(Action, _MaxLines, _MaxCols, State) -> {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. +%% Helper function to ensure exec state is properly initialized +ensure_exec_state(list, State) -> + case State of + #{exec_state := #{path := _, config := _, offset := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {ok, P, C} = erpc:call(Node, maestro_loader, current, []), + State#{exec_state => #{path => P, config => C, offset => {0,0}}} + end; +ensure_exec_state(scan, State) -> + case State of + #{exec_state := #{worker := _, dirs := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {scan, Node, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => P, dirs => DirStatuses}} + end; +ensure_exec_state(sync, State) -> + case State of + #{exec_state := #{worker := _, peer := _, dirs := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + W = start_worker(self(), {sync, Node, P, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => W, peer => P, dirs => DirStatuses}} + end; +ensure_exec_state(status, State) -> + case State of + #{exec_state := #{worker := _, status := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {status, Node}), + State#{exec_state => #{worker => P, status => undefined}} + end; +ensure_exec_state('generate-keys', State) -> + case State of + #{exec_state := #{worker := _, status := _}} -> + %% Do wrapping of the status line + State; + #{exec_args := Args} -> + {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), + {value, #{val := File}} = lists:search(fun(#{name := N}) -> N == certname end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {generate_keys, Path, File}), + State#{exec_state => #{worker => P, status => "generating keys..."}} + end; +ensure_exec_state(seed, State) -> + case State of + #{exec_state := #{worker := _, dirs := _}} -> + %% Do wrapping of the status line + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {seed, Node, Path, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => P, dirs => DirStatuses}} + end; +ensure_exec_state('remote-seed', State) -> + case State of + #{exec_state := #{worker := _, peer := _, dirs := _}} -> + %% Do wrapping of the status line + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + W = start_worker(self(), {'remote-seed', Node, P, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => W, peer => P, dirs => DirStatuses}} + end. + replace([H|T], H, R) -> [R|T]; replace([H|T], S, R) -> [H|replace(T, S, R)]. From fd2c75844a276d0f0b6d9a3c5269eb095f2c5680 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 28 Jun 2025 17:29:19 -0400 Subject: [PATCH 17/27] refactor show_status to be state-driven instead --- cli/revault_cli/src/revault_curses.escript | 51 +++++++++++++--------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 2f6a74f..925d7a9 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -378,16 +378,27 @@ show_exec(State=#{mode := exec, end_table(State=#{exec_coords := {_, {Y,X}}}) -> str(Y+1, 0, ["╚", lists:duplicate(X-1, "═") ,"╝"]), str(Y+2, 0, " ╰─ "), - State#{status_coords => {{Y+1,0}, {Y+2,5}}, - status_init_pos => {Y+2,5}}. - -show_status(State=#{status_init_pos := {Y,X}, - menu_coords := {_, {_,Width}}}, - Str) -> + %% Clear status area and render status if present + #{menu_coords := {_, {_,Width}}} = State, + {StatusY, StatusX} = {Y+2, 5}, + %% Clear the entire status line {MaxY,_} = cecho:getmaxyx(), - [str(LY, X, lists:duplicate(Width-X, $\s)) || LY <- lists:seq(Y,MaxY)], - str(Y, X, Str), - State. + [str(LY, StatusX, lists:duplicate(Width-StatusX, $\s)) || LY <- lists:seq(StatusY,MaxY)], + %% Render status message if present + case State of + #{status_message := StatusMsg} -> + str(StatusY, StatusX, StatusMsg); + _ -> + ok + end, + State#{status_coords => {{Y+1,0}, {StatusY,StatusX}}, + status_init_pos => {StatusY,StatusX}}. + +set_status(State, Str) -> + State#{status_message => Str}. + +clear_status(State) -> + maps:without([status_message], State). loop(OldState) -> State = #{mode := Mode} = state(OldState), @@ -432,10 +443,10 @@ handle_menu({input, Key}, TmpState) -> $\n -> Menu = menu_at(TmpState, Pos), State = enter_menu(TmpState, Menu), - show_status(State, io_lib:format("Entering ~p", [Menu])), + set_status(State, io_lib:format("Entering ~p", [Menu])), {ok, State}; UnknownChar -> - State = show_status( + State = set_status( TmpState, io_lib:format("Unknown menu character: ~w", [UnknownChar]) ), @@ -620,11 +631,11 @@ handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> case {Valid, Invalid} of {_, []} -> %% TODO: change state to execution - State = show_status(TmpState, "ok."), + State = set_status(TmpState, "ok."), {ok, State#{mode => exec, exec_args => Args}}; {_, [{#{line := {Label, _}}, Reason}|_]} -> - State = show_status( + State = set_status( TmpState, io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) ), @@ -632,22 +643,22 @@ handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> end; error -> [{#{line := {Label, _}}, Reason}|_] = Errors, - State = show_status( + State = set_status( TmpState, io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) ), {ok, State} end; handle_action({input, UnknownChar}, Action, TmpState) -> - State = show_status( + State = set_status( TmpState, io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) ), {ok, State}. handle_exec({input, ?ceKEY_ESC}, _Action, TmpState) -> - %% clear up the arg list - State = maps:without([exec_state], TmpState#{mode => action}), + %% clear up the arg list and status messages + State = clear_status(maps:without([exec_state], TmpState#{mode => action})), cecho:erase(), {ok, State}; %% List exec @@ -809,19 +820,19 @@ handle_exec({revault, 'remote-seed', {Dir, Status}}, 'remote-seed', State=#{exec {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; %% Generic exec handle_exec({input, UnknownChar}, Action, TmpState) -> - State = show_status( + State = set_status( TmpState, io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) ), {ok, State}; handle_exec({revault, EventAct, Event}, Act, TmpState) -> - State = show_status( + State = set_status( TmpState, io_lib:format("Got unexpected ~p event in ~p: ~p", [EventAct, Act, Event]) ), {ok, State}; handle_exec(Msg, Action, TmpState) -> - State = show_status( + State = set_status( TmpState, io_lib:format("Got unexpected message in ~p: ~p", [Action, Msg]) ), From 8e321e04602cd609494b12d6b8e6f0a925910c64 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 28 Jun 2025 18:25:49 -0400 Subject: [PATCH 18/27] menu and arguments show docs in status line --- cli/revault_cli/src/revault_curses.escript | 51 ++++++++++++++-------- 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 925d7a9..7fc4791 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -51,6 +51,14 @@ menu_order() -> [list, scan, sync, status, 'generate-keys', seed, 'remote-seed']. +menu_help(list) -> "Show configuration and current settings"; +menu_help(scan) -> "Scan directories for changes"; +menu_help(sync) -> "Synchronize files with remote peer"; +menu_help(status) -> "Display current ReVault instance's configuration status"; +menu_help('generate-keys') -> "Generate TLS certificates for secure connections"; +menu_help(seed) -> "Create initial seed data to a directory, to use in a client"; +menu_help('remote-seed') -> "Create seed data as a client, from remote peer". + args() -> #{list => [ #{name => node, label => "Local Node", @@ -294,15 +302,15 @@ show_menu(State) -> menu_map => MenuMap, menu_coord_map => CoordMap, menu_init_pos => {1,2}}, - %% set cursor if in menu mode + %% set cursor if in menu mode and show help for hovered item case {Mode, Hover} of {menu, Hover} -> MoveTo = menu_pos(NewState, Hover), - mv(MoveTo); + mv(MoveTo), + set_status(NewState, menu_help(Hover)); _ -> - ok - end, - NewState. + NewState + end. show_action(State = #{mode := Mode, menu_coords := {_, End}}) when Mode == menu -> @@ -329,29 +337,33 @@ show_action(State = #{mode := Mode, menu := Action, end, {MinY, []}, Args), Ranges = lists:reverse(RevRanges), %% Set position - case cecho:getyx() of + CurrentY = case cecho:getyx() of {CurY, CurX} when CurY >= MinY, CurY =< MaxY, CurX >= 2, CurX =< MaxX -> - ok; + CurY; _ -> %% Outside the box, move it to a known location case Ranges of [] -> - mv({MinY, 2}); + mv({MinY, 2}), + MinY; [#{range := {First, _Last}}|_] -> - mv(First) + mv(First), + element(1, First) end end, - ExtraLines = case Mode of + {ExtraLines, TmpState} = case Mode of action -> - 0; % this is the last section + NthArg = CurrentY - MinY, + #{help := Help} = lists:nth(NthArg, Args), + {0, maybe_set_status(ArgState, Help)}; % this is the last section _ -> %% terminate table section BottomRow = ["╟", lists:duplicate(MaxX-1, "─"), "╢"], str(MaxY+1, 0, BottomRow), - 1 + {1, ArgState} end, - ArgState#{action_coords => {{MinY,0}, {MaxY+ExtraLines,MaxX}}, + TmpState#{action_coords => {{MinY,0}, {MaxY+ExtraLines,MaxX}}, action_args => Ranges, action_init_pos => {MinY, 2}}. @@ -379,11 +391,10 @@ end_table(State=#{exec_coords := {_, {Y,X}}}) -> str(Y+1, 0, ["╚", lists:duplicate(X-1, "═") ,"╝"]), str(Y+2, 0, " ╰─ "), %% Clear status area and render status if present - #{menu_coords := {_, {_,Width}}} = State, {StatusY, StatusX} = {Y+2, 5}, %% Clear the entire status line - {MaxY,_} = cecho:getmaxyx(), - [str(LY, StatusX, lists:duplicate(Width-StatusX, $\s)) || LY <- lists:seq(StatusY,MaxY)], + {MaxY,MaxX} = cecho:getmaxyx(), + [str(LY, StatusX, lists:duplicate(MaxX-StatusX, $\s)) || LY <- lists:seq(StatusY,MaxY)], %% Render status message if present case State of #{status_message := StatusMsg} -> @@ -397,6 +408,9 @@ end_table(State=#{exec_coords := {_, {Y,X}}}) -> set_status(State, Str) -> State#{status_message => Str}. +maybe_set_status(State=#{status_message := _}, _) -> State; +maybe_set_status(State, Str) -> set_status(State, Str). + clear_status(State) -> maps:without([status_message], State). @@ -413,7 +427,7 @@ loop(OldState) -> #{menu := Action} = State, receive {input, Input} -> - {ok, NewState} = handle_action({input, Input}, Action, State), + {ok, NewState} = handle_action({input, Input}, Action, clear_status(State)), loop(NewState) end; exec -> @@ -442,8 +456,7 @@ handle_menu({input, Key}, TmpState) -> {ok, State}; $\n -> Menu = menu_at(TmpState, Pos), - State = enter_menu(TmpState, Menu), - set_status(State, io_lib:format("Entering ~p", [Menu])), + State = clear_status(enter_menu(TmpState, Menu)), {ok, State}; UnknownChar -> State = set_status( From 826ac5e7d6614cb6c69e4f9ad24fd8a31d798726 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Tue, 1 Jul 2025 20:53:51 -0400 Subject: [PATCH 19/27] refactor argument validation in loop --- cli/revault_cli/src/revault_curses.escript | 77 ++++++++++++---------- 1 file changed, 41 insertions(+), 36 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 7fc4791..a79f84a 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -619,43 +619,11 @@ handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> %% is found, show it in the status line. %% if none are found, extract as clean options, and %% switch to execution mode. - {Errors, Status} = lists:foldl( - fun(Arg = #{line := {_Label, Str}}, {Acc, S}) -> - case parse_arg(TmpState, Action, Arg, Str) of - {ok, _, _} -> {Acc, S}; - {error, Reason, _} -> {[{Arg, Reason}|Acc], error} - end - end, - {[], ok}, - Args - ), - case Status of + case validate_args(TmpState, Action, Args) of ok -> - {Valid, Invalid} = lists:foldl( - fun(Arg = #{val := Val, type := {_,_,F}}, {V,I}) -> - case F(TmpState, Val) of - ok -> {[Arg|V], I}; - {error, Reason} -> {V, [{Arg, Reason}]} - end - end, - {[],[]}, - Args - ), - case {Valid, Invalid} of - {_, []} -> - %% TODO: change state to execution - State = set_status(TmpState, "ok."), - {ok, State#{mode => exec, - exec_args => Args}}; - {_, [{#{line := {Label, _}}, Reason}|_]} -> - State = set_status( - TmpState, - io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) - ), - {ok, State} - end; - error -> - [{#{line := {Label, _}}, Reason}|_] = Errors, + State = set_status(TmpState, "ok."), + {ok, State#{mode => exec, exec_args => Args}}; + {error, [{#{line := {Label, _}}, Reason}|_]} -> State = set_status( TmpState, io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) @@ -1489,3 +1457,40 @@ wrap(Str, W, Width, Ln, Lines, [L|Acc]) -> [] -> lists:reverse([lists:reverse(L)|Acc]) end. + +validate_args(State, Action, Args) -> + %% Validate all the arguments + {Errors, Status} = lists:foldl( + fun(Arg = #{line := {_Label, Str}}, {Acc, S}) -> + case parse_arg(State, Action, Arg, Str) of + {ok, _, _} -> {Acc, S}; + {error, Reason, _} -> {[{Arg, Reason}|Acc], error} + end + end, + {[], ok}, + Args + ), + case Status of + error -> + {error, Errors}; + ok -> + {_Valid, Invalid} = convert_args(State, Args), + case Invalid of + [] -> + ok; + Invalid -> + {error, Invalid} + end + end. + +convert_args(State, Args) -> + lists:foldl( + fun(Arg = #{val := Val, type := {_,_,F}}, {V,I}) -> + case F(State, Val) of + ok -> {[Arg|V], I}; + {error, Reason} -> {V, [{Arg, Reason}]} + end + end, + {[],[]}, + Args + ). From f9668322955bd639bcfec13efbb5f5df410bc727 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Fri, 4 Jul 2025 08:10:59 -0400 Subject: [PATCH 20/27] remove ESC key delay --- cli/revault_cli/rebar.config | 2 +- cli/revault_cli/src/revault_curses.escript | 3 +++ rebar.config | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/cli/revault_cli/rebar.config b/cli/revault_cli/rebar.config index 7b69732..2ea5211 100644 --- a/cli/revault_cli/rebar.config +++ b/cli/revault_cli/rebar.config @@ -1,2 +1,2 @@ {deps, [argparse, - {cecho, {git, "https://github.com/mazenharake/cecho.git", {branch, "master"}}}]}. + {cecho, {git, "https://github.com/ferd/cecho.git", {branch, "master"}}}]}. diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index a79f84a..69d4c5a 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -247,6 +247,8 @@ setup() -> cecho:noecho(), %% give keypad access cecho:keypad(?ceSTDSCR, true), + %% don't wait on ESC keys + cecho:set_escdelay(25), %% initial cursor position cecho:move(1,1), ok. @@ -638,6 +640,7 @@ handle_action({input, UnknownChar}, Action, TmpState) -> {ok, State}. handle_exec({input, ?ceKEY_ESC}, _Action, TmpState) -> + %% TODO: clean up workers if any %% clear up the arg list and status messages State = clear_status(maps:without([exec_state], TmpState#{mode => action})), cecho:erase(), diff --git a/rebar.config b/rebar.config index 7f44484..af5c29b 100644 --- a/rebar.config +++ b/rebar.config @@ -75,7 +75,7 @@ ]} ]}, {ncurses, [ - {deps, [{cecho, {git, "https://github.com/mazenharake/cecho.git", {branch, "master"}}}]} + {deps, [{cecho, {git, "https://github.com/ferd/cecho.git", {branch, "master"}}}]} ]}, {debug, [ %% generate debug traces in gen_* processes From 5d9a5698a0c69458d13da1f9fca639abca2db17a Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Fri, 4 Jul 2025 08:51:35 -0400 Subject: [PATCH 21/27] abstract away action rendering structure --- cli/revault_cli/src/revault_curses.escript | 80 ++++++++++++---------- 1 file changed, 44 insertions(+), 36 deletions(-) diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript index 69d4c5a..d70c531 100644 --- a/cli/revault_cli/src/revault_curses.escript +++ b/cli/revault_cli/src/revault_curses.escript @@ -321,7 +321,7 @@ show_action(State = #{mode := Mode, menu := Action, menu_coords := {_, {MenuY, MaxX}}}) when Mode == action; Mode == exec -> MinY = MenuY, - %% TODO: truncate lines that are too long + %% TODO: clip lines that are too long {ArgState, Args} = arg_output(State, Action, maps:get(action_args, State, maps:get(Action, args(), []))), @@ -379,14 +379,21 @@ show_exec(State=#{mode := exec, %% expect line-based output in a list MaxLines = ?EXEC_LINES, MaxCols = MaxX-4, - {ExecState, Strs} = render_exec(Action, MaxLines, MaxCols, State), MaxY = MinY + MaxLines, - LinesY = lists:foldl(fun(Line, Y) -> - str(Y+1, 0, ["║ ", string:pad(Line, MaxX-3), " ║"]), - Y+1 - end, MinY, Strs), - [str(LineY, 0, ["║", lists:duplicate(MaxX-1, " "), "║"]) - || LineY <- lists:seq(LinesY+1, MaxY)], + {RenderMode, ExecState, Lines} = render_exec(Action, MaxLines, MaxCols, State), + case RenderMode of + raw -> + render_raw(Lines, {MinY,0}, {MaxY, MaxX}); + wrap -> + render_raw(wrap(Lines, MaxLines, MaxCols), + {MinY,0}, {MaxY, MaxX}); + clip -> + render_raw(clip(Lines, {0,0}, MaxLines, MaxCols), + {MinY,0}, {MaxY, MaxX}); + {clip, Offsets} -> + render_raw(clip(Lines, Offsets, MaxLines, MaxCols), + {MinY,0}, {MaxY, MaxX}) + end, ExecState#{exec_coords => {{MinY,0},{MaxY,MaxX}}}. end_table(State=#{exec_coords := {_, {Y,X}}}) -> @@ -416,6 +423,14 @@ maybe_set_status(State, Str) -> set_status(State, Str). clear_status(State) -> maps:without([status_message], State). +render_raw(Lines, {MinY, MinX}, {MaxY, MaxX}) -> + LinesY = lists:foldl(fun(Line, Y) -> + str(Y+1, MinX, ["║ ", string:pad(Line, MaxX-MinX-3), " ║"]), + Y+1 + end, MinY, Lines), + [str(LineY, MinX, ["║", lists:duplicate(MaxX-MinX-1, " "), "║"]) + || LineY <- lists:seq(LinesY+1, MaxY)]. + loop(OldState) -> State = #{mode := Mode} = state(OldState), case Mode of @@ -945,41 +960,35 @@ parse_arg(State, _Action, #{type := TypeInfo}, Unparsed) -> parse_with_fun(T, F, Unparsed, State) end. -render_exec(list, MaxLines, MaxCols, State) -> +render_exec(list, _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state(list, State), #{exec_state := #{path := Path, config := Config, offset := {OffY,OffX}}} = NewState, Brk = io_lib:format("~n", []), Str = io_lib:format("Config parsed from ~ts:~n~p~n", [Path, Config]), %% Fit lines and the whole thing in a "box" Lines = string:lexemes(Str, Brk), - Truncated = [string:slice(S, OffX, MaxCols) - || S <- lists:sublist(Lines, OffY+1, MaxLines)], - {NewState, Truncated}; -render_exec(scan, MaxLines, MaxCols, State) -> + {{clip, {OffY,OffX}}, NewState, Lines}; +render_exec(scan, _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state(scan, State), #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), %% TODO: support scrolling if you have more Dirs than MaxLines or - %% dirs that are too long. + %% dirs that are too long by tracking clipping offsets. LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - true = MaxLines >= length(LStatuses), - true = MaxCols >= LongestDir + 4, % 4 chars for the status display room Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", case Status of pending -> "??"; ok -> "ok"; _ -> "!!" end] || {Dir, Status} <- LStatuses], - {NewState, Strs}; -render_exec(sync, MaxLines, MaxCols, State) -> + {clip, NewState, Strs}; +render_exec(sync, _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state(sync, State), #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), %% TODO: support scrolling if you have more Dirs than MaxLines or - %% dirs that are too long. + %% dirs that are too long by tracking clipping offsets LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - true = MaxLines >= length(LStatuses), - true = MaxCols >= LongestDir + 4, % 4 chars for the status display room Header = [string:pad("DIR", LongestDir+1, trailing, " "), " SCAN SYNC"], Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", case Status of @@ -988,47 +997,42 @@ render_exec(sync, MaxLines, MaxCols, State) -> synced -> " ok ok"; _ -> " !! !!" end] || {Dir, Status} <- LStatuses], - {NewState, [Header | Strs]}; + {clip, NewState, [Header | Strs]}; render_exec(status, _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state(status, State), #{exec_state := #{status := Status}} = NewState, Strs = [io_lib:format("~p",[Status])], - {NewState, Strs}; -render_exec('generate-keys', MaxLines, MaxCols, State) -> + {wrap, NewState, Strs}; +render_exec('generate-keys', _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state('generate-keys', State), #{exec_state := #{status := Exists}} = NewState, - Strs = wrap(Exists, MaxCols, MaxLines), - {NewState, Strs}; -render_exec(seed, MaxLines, MaxCols, State) -> + {wrap, NewState, Exists}; +render_exec(seed, _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state(seed, State), #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - true = MaxLines >= length(LStatuses), - true = MaxCols >= LongestDir + 4, % 4 chars for the status display room Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", case Status of pending -> "??"; ok -> "ok"; _ -> "!!" end] || {Dir, Status} <- LStatuses], - {NewState, Strs}; -render_exec('remote-seed', MaxLines, MaxCols, State) -> + {clip, NewState, Strs}; +render_exec('remote-seed', _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state('remote-seed', State), #{exec_state := #{dirs := Statuses}} = NewState, LStatuses = lists:sort(maps:to_list(Statuses)), LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - true = MaxLines >= length(LStatuses), - true = MaxCols >= LongestDir + 4, % 4 chars for the status display room Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", case Status of pending -> "??"; {ok, _ITC} -> "ok"; _ -> "!!" end] || {Dir, Status} <- LStatuses], - {NewState, Strs}; + {clip, NewState, Strs}; render_exec(Action, _MaxLines, _MaxCols, State) -> - {State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. + {clip, State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. %% Helper function to ensure exec state is properly initialized ensure_exec_state(list, State) -> @@ -1444,7 +1448,7 @@ check_openssl_vsn("Open", A, B, C) when A > 1; check_openssl_vsn(_, _, _, _) -> error(bad_vsn). -wrap(Str, Width, Lines) -> +wrap(Str, Lines, Width) -> wrap(Str, 0, Width, 0, Lines, [[]]). wrap(_Str, Width, Width, Lines, Lines, Acc) -> @@ -1461,6 +1465,10 @@ wrap(Str, W, Width, Ln, Lines, [L|Acc]) -> lists:reverse([lists:reverse(L)|Acc]) end. +clip(Lines, {OffY, OffX}, MaxLines, MaxCols) -> + [string:slice(S, OffX, MaxCols) + || S <- lists:sublist(Lines, OffY+1, MaxLines)]. + validate_args(State, Action, Args) -> %% Validate all the arguments {Errors, Status} = lists:foldl( From 859623be3bec6f4be533a41b56e6c49faad39ff1 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 5 Jul 2025 16:38:54 -0400 Subject: [PATCH 22/27] WIP: split TUI, turn to app --- cli/revault_cli/src/revault_cli.app.src | 1 + cli/revault_cli/src/revault_cli.hrl | 11 + cli/revault_cli/src/revault_cli_app.erl | 9 + cli/revault_cli/src/revault_cli_mod.erl | 802 ++++++++++++++++++++++++ cli/revault_cli/src/revault_curses.erl | 745 ++++++++++++++++++++++ config/cli.args.src | 3 + config/cli.sys.config | 1 + rebar.config | 7 +- 8 files changed, 1578 insertions(+), 1 deletion(-) create mode 100644 cli/revault_cli/src/revault_cli.hrl create mode 100644 cli/revault_cli/src/revault_cli_app.erl create mode 100644 cli/revault_cli/src/revault_cli_mod.erl create mode 100644 cli/revault_cli/src/revault_curses.erl create mode 100644 config/cli.args.src create mode 100644 config/cli.sys.config diff --git a/cli/revault_cli/src/revault_cli.app.src b/cli/revault_cli/src/revault_cli.app.src index dc57dcc..36e371a 100644 --- a/cli/revault_cli/src/revault_cli.app.src +++ b/cli/revault_cli/src/revault_cli.app.src @@ -2,6 +2,7 @@ [{description, "An escript to interact with ReVault nodes"}, {vsn, "0.1.0"}, {registered, []}, + {mod, {revault_cli_app, []}}, {applications, [kernel, stdlib, diff --git a/cli/revault_cli/src/revault_cli.hrl b/cli/revault_cli/src/revault_cli.hrl new file mode 100644 index 0000000..9363b50 --- /dev/null +++ b/cli/revault_cli/src/revault_cli.hrl @@ -0,0 +1,11 @@ +-define(KEY_BACKSPACE, 127). +-define(KEY_CTRLA, 1). +-define(KEY_CTRLE, 5). +-define(KEY_CTRLD, 4). +-define(KEY_ENTER, 10). +-define(KEY_TEXT_RANGE(X), % ignore control codes + (not(X < 32) andalso + not(X >= 127 andalso X < 160))). + +-define(EXEC_LINES, 15). +-define(MAX_VALIDATION_DELAY, 150). % longest time to validate input, in ms diff --git a/cli/revault_cli/src/revault_cli_app.erl b/cli/revault_cli/src/revault_cli_app.erl new file mode 100644 index 0000000..9c5bb00 --- /dev/null +++ b/cli/revault_cli/src/revault_cli_app.erl @@ -0,0 +1,9 @@ +-module(revault_cli_app). +-behaviour(application). +-export([start/2, stop/1]). + +start(_Type, _Args) -> + revault_curses:start_link(revault_cli_mod). + +stop(_State) -> + ok. diff --git a/cli/revault_cli/src/revault_cli_mod.erl b/cli/revault_cli/src/revault_cli_mod.erl new file mode 100644 index 0000000..831ea45 --- /dev/null +++ b/cli/revault_cli/src/revault_cli_mod.erl @@ -0,0 +1,802 @@ +-module(revault_cli_mod). +-behaviour(revault_curses). +-include("revault_cli.hrl"). +-include_lib("cecho/include/cecho.hrl"). + +-define(DEFAULT_NODE, list_to_atom("revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@"))))). + +-export([menu_order/0, menu_help/1, args/0, + render_exec/4, handle_exec/3]). + +%%%%%%%%%%%%%%%%% +%%% CALLBACKS %%% +%%%%%%%%%%%%%%%%% +menu_order() -> + [list, scan, sync, status, 'generate-keys', seed, 'remote-seed']. + +menu_help(list) -> "Show configuration and current settings"; +menu_help(scan) -> "Scan directories for changes"; +menu_help(sync) -> "Synchronize files with remote peer"; +menu_help(status) -> "Display current ReVault instance's configuration status"; +menu_help('generate-keys') -> "Generate TLS certificates for secure connections"; +menu_help(seed) -> "Create initial seed data to a directory, to use in a client"; +menu_help('remote-seed') -> "Create seed data as a client, from remote peer". + +args() -> + #{list => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"} + ], + scan => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, + help => "Local ReVault instance to connect to"}, + #{name => dirs, label => "Dirs", + type => {list, fun revault_curses:parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, + help => "List of directories to scan"} + ], + sync => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, + help => "Local ReVault instance to connect to"}, + #{name => dirs, label => "Dirs", + type => {list, fun revault_curses:parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, + help => "List of directories to scan"}, + #{name => peer, label => "Peer Node", + type => {string, "^(?:\\s*)?(.+)(?:\\s*)?$", fun check_peer/2}, default => fun default_peers/1, + help => "Peer to sync against"} + ], + status => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"} + ], + 'generate-keys' => [ + #{name => certname, label => "Certificate Name", + % the string regex 'trims' leading and trailing whitespace + type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "revault", + help => "Name of the key files generated"}, + #{name => path, label => "Certificate Directory", + type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "./", + help => "Directory where the key files will be placed"} + ], + seed => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"}, + #{name => path, label => "Fork Seed Directory", + type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "./forked/", + help => "path of the base directory where the forked data will be located."}, + #{name => dirs, label => "Dirs", + type => {list, fun revault_curses:parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, + help => "List of directories to fork"} + ], + 'remote-seed' => [ + #{name => node, label => "Local Node", + type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, + help => "ReVault instance to connect to"}, + #{name => peer, label => "Peer Node", + type => {string, "^(?:\\s*)?(.+)(?:\\s*)?$", fun check_peer/2}, default => fun default_peers/1, + help => "Peer from which to fork a seed"}, + #{name => dirs, label => "Dirs", + %% TODO: replace list by 'peer_dirs' + type => {list, fun revault_curses:parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, + help => "List of directories to fork"} + ] + }. + + +render_exec(list, _MaxLines, _MaxCols, State) -> + NewState = ensure_exec_state(list, State), + #{exec_state := #{path := Path, config := Config, offset := {OffY,OffX}}} = NewState, + Brk = io_lib:format("~n", []), + Str = io_lib:format("Config parsed from ~ts:~n~p~n", [Path, Config]), + %% Fit lines and the whole thing in a "box" + Lines = string:lexemes(Str, Brk), + {{clip, {OffY,OffX}}, NewState, Lines}; +render_exec(scan, _MaxLines, _MaxCols, State) -> + NewState = ensure_exec_state(scan, State), + #{exec_state := #{dirs := Statuses}} = NewState, + LStatuses = lists:sort(maps:to_list(Statuses)), + %% TODO: support scrolling if you have more Dirs than MaxLines or + %% dirs that are too long by tracking clipping offsets. + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> "??"; + ok -> "ok"; + _ -> "!!" + end] || {Dir, Status} <- LStatuses], + {clip, NewState, Strs}; +render_exec(sync, _MaxLines, _MaxCols, State) -> + NewState = ensure_exec_state(sync, State), + #{exec_state := #{dirs := Statuses}} = NewState, + LStatuses = lists:sort(maps:to_list(Statuses)), + %% TODO: support scrolling if you have more Dirs than MaxLines or + %% dirs that are too long by tracking clipping offsets + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + Header = [string:pad("DIR", LongestDir+1, trailing, " "), " SCAN SYNC"], + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> " ??"; + scanned -> " ok ??"; + synced -> " ok ok"; + _ -> " !! !!" + end] || {Dir, Status} <- LStatuses], + {clip, NewState, [Header | Strs]}; +render_exec(status, _MaxLines, _MaxCols, State) -> + NewState = ensure_exec_state(status, State), + #{exec_state := #{status := Status}} = NewState, + Strs = [io_lib:format("~p",[Status])], + {wrap, NewState, Strs}; +render_exec('generate-keys', _MaxLines, _MaxCols, State) -> + NewState = ensure_exec_state('generate-keys', State), + #{exec_state := #{status := Exists}} = NewState, + {wrap, NewState, Exists}; +render_exec(seed, _MaxLines, _MaxCols, State) -> + NewState = ensure_exec_state(seed, State), + #{exec_state := #{dirs := Statuses}} = NewState, + LStatuses = lists:sort(maps:to_list(Statuses)), + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> "??"; + ok -> "ok"; + _ -> "!!" + end] || {Dir, Status} <- LStatuses], + {clip, NewState, Strs}; +render_exec('remote-seed', _MaxLines, _MaxCols, State) -> + NewState = ensure_exec_state('remote-seed', State), + #{exec_state := #{dirs := Statuses}} = NewState, + LStatuses = lists:sort(maps:to_list(Statuses)), + LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), + Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", + case Status of + pending -> "??"; + {ok, _ITC} -> "ok"; + _ -> "!!" + end] || {Dir, Status} <- LStatuses], + {clip, NewState, Strs}; +render_exec(Action, _MaxLines, _MaxCols, State) -> + {clip, State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. + + +handle_exec({input, ?ceKEY_ESC}, _Action, TmpState) -> + %% TODO: clean up workers if any + %% clear up the arg list and status messages + State = revault_curses:clear_status(maps:without([exec_state], TmpState#{mode => action})), + cecho:erase(), + {ok, State}; +%% List exec +handle_exec({input, ?ceKEY_DOWN}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {Y+1, X}}}}; +handle_exec({input, ?ceKEY_UP}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {max(0,Y-1), X}}}}; +handle_exec({input, ?ceKEY_RIGHT}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {Y, X+1}}}}; +handle_exec({input, ?ceKEY_LEFT}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + {ok, State#{exec_state => ES#{offset => {Y, max(0,X-1)}}}}; +handle_exec({input, ?ceKEY_PGDOWN}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + Shift = ?EXEC_LINES-1, + {ok, State#{exec_state => ES#{offset => {Y+Shift, X}}}}; +handle_exec({input, ?ceKEY_PGUP}, list, State = #{exec_state:=ES}) -> + {Y,X} = maps:get(offset, ES, {0, 0}), + Shift = ?EXEC_LINES-1, + {ok, State#{exec_state => ES#{offset => {max(0,Y-Shift), X}}}}; +%% TODO: ctrlA, ctrlE +%% Scan exec +handle_exec({revault, scan, done}, scan, State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, scan, {Dir, Status}}, scan, State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; +handle_exec({input, ?KEY_ENTER}, scan, State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {revault, scan, done}, + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; +%% Sync exec +handle_exec({revault, sync, done}, sync, State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, sync, {Dir, Status}}, sync, State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; +handle_exec({input, ?KEY_ENTER}, sync, State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {revault, scan, done}, + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; +%% Status +handle_exec({revault, status, done}, status, State) -> + {ok, State}; +handle_exec({revault, status, {ok, Val}}, status, State=#{exec_state:=ES}) -> + {ok, State#{exec_state => ES#{status => Val}}}; +handle_exec({input, ?KEY_ENTER}, status, State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {revault, status, done}, + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; +%% Generate-Keys +handle_exec({revault, 'generate-keys', {ok, Val}}, 'generate-keys', State=#{exec_state:=ES}) -> + {ok, State#{exec_state => ES#{status => Val}}}; +handle_exec({input, ?KEY_ENTER}, 'generate-keys', State) -> + %% Do a refresh by exiting the menu and re-entering again. Quite hacky. + self() ! {input, ?ceKEY_ESC}, + self() ! {input, ?KEY_ENTER}, + {ok, State}; +%% Seed exec +handle_exec({revault, seed, done}, seed, State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, seed, {Dir, Status}}, seed, State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; +% do not endlessly re-seed? +% handle_exec({input, ?KEY_ENTER}, seed, State) -> +% %% Do a refresh by exiting the menu and re-entering again. Quite hacky. +% self() ! {revault, seed, done}, +% self() ! {input, ?ceKEY_ESC}, +% self() ! {input, ?KEY_ENTER}, +% {ok, State}; +%% remote-seed exec +handle_exec({revault, 'remote-seed', done}, 'remote-seed', State=#{exec_state:=ES}) -> + %% unset the workers + case maps:get(worker, ES, undefined) of + undefined -> + ok; + Pid -> + %% make sure the worker is torn down fully, even + %% if this is blocking + Pid ! done, + Ref = erlang:monitor(process, Pid), + receive + {'DOWN', Ref, process, _, _} -> + ok + after 5000 -> + %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY + %% so consider this a hard failure. + error(bad_worker_shutdown) + end + end, + {ok, State}; +handle_exec({revault, 'remote-seed', {Dir, Status}}, 'remote-seed', State=#{exec_state:=ES}) -> + #{dirs := Statuses} = ES, + file:write_file("/tmp/dbg", io_lib:format("~p~n", [{Dir, Status}])), + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; +%% Generic exec +handle_exec({input, UnknownChar}, Action, TmpState) -> + State = revault_curses:set_status( + TmpState, + io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) + ), + {ok, State}; +handle_exec({revault, EventAct, Event}, Act, TmpState) -> + State = revault_curses:set_status( + TmpState, + io_lib:format("Got unexpected ~p event in ~p: ~p", [EventAct, Act, Event]) + ), + {ok, State}; +handle_exec(Msg, Action, TmpState) -> + State = revault_curses:set_status( + TmpState, + io_lib:format("Got unexpected message in ~p: ~p", [Action, Msg]) + ), + {ok, State}. + + +%%%%%%%%%%%%%%%%%%%% +%%% ARGS HELPERS %%% +%%%%%%%%%%%%%%%%%%%% +default_dirs(#{local_node := Node}) -> + try config(Node) of + {config, _Path, Config} -> + #{<<"dirs">> := DirMap} = Config, + maps:keys(DirMap) + catch + _E:_R -> [] + end. + +default_peers(State = #{local_node := Node}) -> + DirList = maps:get(dir_list, State, []), + try config(Node) of + {config, _Path, Config} -> + #{<<"peers">> := PeerMap} = Config, + Needed = ordsets:from_list(DirList), + Peers = [Peer + || Peer <- maps:keys(PeerMap), + Dirs <- [maps:get(<<"sync">>, maps:get(Peer, PeerMap))], + ordsets:is_subset(Needed, ordsets:from_list(Dirs))], + %% Flatten into a string, since peer data espects a string. + unicode:characters_to_binary(lists:join(", ", Peers)) + catch + _E:_R -> [] + end. + +check_connect(State, Node) -> + case revault_curses:check_connect(State, Node) of + ok -> + case revault_node(Node) of + ok -> ok; + _ -> {error, partial_success} + end; + Error -> + Error + end. + +check_dirs(#{local_node := Node}, Dirs) -> + try config(Node) of + {config, _Path, Config} -> + #{<<"dirs">> := DirMap} = Config, + ValidDirs = maps:keys(DirMap), + case Dirs -- ValidDirs of + [] -> ok; + Others -> {error, {unknown_dirs, Others}} + end + catch + E:R -> {error, {E,R}} + end. + +check_peer(State = #{local_node := Node}, Peer) -> + DirList = maps:get(dir_list, State, []), + try config(Node) of + {config, _Path, Config} -> + #{<<"peers">> := PeerMap} = Config, + Peers = [ValidPeer + || ValidPeer <- maps:keys(PeerMap)], + case lists:member(Peer, Peers) of + true -> + Needed = ordsets:from_list(DirList), + PeerDirs = maps:get(<<"sync">>, maps:get(Peer, PeerMap, #{}), []), + case ordsets:is_subset(Needed, ordsets:from_list(PeerDirs)) of + true -> ok; + false -> {error, {mismatching_dirs, Peer, Needed, PeerDirs}} + end; + false -> + {error, {unknown_peer, Peer, Peers}} + end + catch + E:R -> {error, {E,R}} + end. + +check_ignore(_, _) -> + ok. + +-spec revault_node(atom()) -> ok | {error, term()}. +revault_node(Node) -> + try erpc:call(Node, maestro_loader, status, []) of + current -> ok; + outdated -> ok; + last_valid -> ok; + _ -> {error, unknown_status} + catch + E:R -> {error, {rpc, {E,R}}} + end. + +config(Node) -> + {ok, Path, Config} = erpc:call(Node, maestro_loader, current, []), + {config, Path, Config}. + +%%%%%%%%%%%%%%%%%%%% +%%% EXEC HELPERS %%% +%%%%%%%%%%%%%%%%%%%% +%% Helper function to ensure exec state is properly initialized +ensure_exec_state(list, State) -> + case State of + #{exec_state := #{path := _, config := _, offset := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {ok, P, C} = erpc:call(Node, maestro_loader, current, []), + State#{exec_state => #{path => P, config => C, offset => {0,0}}} + end; +ensure_exec_state(scan, State) -> + case State of + #{exec_state := #{worker := _, dirs := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {scan, Node, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => P, dirs => DirStatuses}} + end; +ensure_exec_state(sync, State) -> + case State of + #{exec_state := #{worker := _, peer := _, dirs := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + W = start_worker(self(), {sync, Node, P, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => W, peer => P, dirs => DirStatuses}} + end; +ensure_exec_state(status, State) -> + case State of + #{exec_state := #{worker := _, status := _}} -> + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {status, Node}), + State#{exec_state => #{worker => P, status => undefined}} + end; +ensure_exec_state('generate-keys', State) -> + case State of + #{exec_state := #{worker := _, status := _}} -> + %% Do wrapping of the status line + State; + #{exec_args := Args} -> + {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), + {value, #{val := File}} = lists:search(fun(#{name := N}) -> N == certname end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {generate_keys, Path, File}), + State#{exec_state => #{worker => P, status => "generating keys..."}} + end; +ensure_exec_state(seed, State) -> + case State of + #{exec_state := #{worker := _, dirs := _}} -> + %% Do wrapping of the status line + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + P = start_worker(self(), {seed, Node, Path, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => P, dirs => DirStatuses}} + end; +ensure_exec_state('remote-seed', State) -> + case State of + #{exec_state := #{worker := _, peer := _, dirs := _}} -> + %% Do wrapping of the status line + State; + #{exec_args := Args} -> + {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), + {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + %% TODO: replace with an alias + W = start_worker(self(), {'remote-seed', Node, P, Dirs}), + DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), + State#{exec_state => #{worker => W, peer => P, dirs => DirStatuses}} + end. + +%%%%%%%%%%%%%%%%%%%%% +%%% ASYNC WORKERS %%% +%%%%%%%%%%%%%%%%%%%%% +start_worker(ReplyTo, Call) -> + Parent = self(), + spawn_link(fun() -> worker(Parent, ReplyTo, Call) end). + +worker(Parent, ReplyTo, {scan, Node, Dirs}) -> + worker_scan(Parent, ReplyTo, Node, Dirs); +worker(Parent, ReplyTo, {sync, Node, Peer, Dirs}) -> + worker_sync(Parent, ReplyTo, Node, Peer, Dirs); +worker(Parent, ReplyTo, {status, Node}) -> + worker_status(Parent, ReplyTo, Node); +worker(Parent, ReplyTo, {generate_keys, Path, File}) -> + worker_generate_keys(Parent, ReplyTo, Path, File); +worker(Parent, ReplyTo, {seed, Node, Path, Dirs}) -> + worker_seed(Parent, ReplyTo, Node, Path, Dirs); +worker(Parent, ReplyTo, {'remote-seed', Node, Peer, Dirs}) -> + worker_remote_seed(Parent, ReplyTo, Node, Peer, Dirs). + +worker_scan(Parent, ReplyTo, Node, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so scan them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_dirmon_event, force_scan, [Dir, infinity], + Dir, Ids) + end, erpc:reqids_new(), Dirs), + worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds). + +worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, scan, done}, + exit(normal); + no_response -> + worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds); + {{response, Res}, Dir, NewIds} -> + ReplyTo ! {revault, scan, {Dir, Res}}, + worker_scan_loop(Parent, ReplyTo, Node, Dirs, NewIds) + end + end. + +worker_sync(Parent, ReplyTo, Node, Peer, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so sync them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_dirmon_event, force_scan, [Dir, infinity], + {scan, Dir}, Ids) + end, erpc:reqids_new(), Dirs), + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds). + +worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, sync, done}, + exit(normal); + no_response -> + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); + {{response, Res}, {scan, Dir}, TmpIds} -> + Status = case Res of + ok -> scanned; + Other -> Other + end, + ReplyTo ! {revault, sync, {Dir, Status}}, + NewIds = erpc:send_request( + Node, + revault_fsm, sync, [Dir, Peer], + {sync, Dir}, + TmpIds + ), + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds); + {{response, Res}, {sync, Dir}, NewIds} -> + Status = case Res of + ok -> synced; + Other -> Other + end, + ReplyTo ! {revault, sync, {Dir, Status}}, + worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) + end + end. + +worker_status(Parent, ReplyTo, Node) -> + process_flag(trap_exit, true), + ReqIds = erpc:send_request(Node, + maestro_loader, status, [], + status, erpc:reqids_new()), + worker_status_loop(Parent, ReplyTo,ReqIds). + +worker_status_loop(Parent, ReplyTo, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + exit(Reason); + stop -> + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, status, done}, + exit(normal); + no_response -> + worker_status_loop(Parent, ReplyTo, ReqIds); + {{response, Res}, status, NewIds} -> + ReplyTo ! {revault, status, {ok, Res}}, + worker_status_loop(Parent, ReplyTo, NewIds) + end + end. + + +worker_generate_keys(Parent, ReplyTo, Path, File) -> + Res = make_selfsigned_cert(unicode:characters_to_list(Path), + unicode:characters_to_list(File)), + %% we actually don't have a loop, everything is local + %% and has already be run, so we just wait for a shutdown signal. + ReplyTo ! {revault, 'generate-keys', {ok, Res}}, + receive + {'EXIT', Parent, Reason} -> + exit(Reason); + stop -> + unlink(parent), + exit(shutdown) + end. + +worker_seed(Parent, ReplyTo, Node, Path, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so scan them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_fsm, seed_fork, [Dir, Path], + Dir, Ids) + end, erpc:reqids_new(), Dirs), + worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds). + +worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, seed, done}, + exit(normal); + no_response -> + worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds); + {{response, Res}, Dir, NewIds} -> + ReplyTo ! {revault, seed, {Dir, Res}}, + worker_seed_loop(Parent, ReplyTo, Node, Dirs, NewIds) + end + end. + +worker_remote_seed(Parent, ReplyTo, Node, Peer, Dirs) -> + %% assume we are connected from arg validation time. + %% We have multiple directories, so scan them in parallel. + %% This requires setting up sub-workers, which incidentally lets us + %% also listen for interrupts from the parent. + process_flag(trap_exit, true), + ReqIds = lists:foldl(fun(Dir, Ids) -> + erpc:send_request(Node, + revault_fsm, id, [Dir, Peer], + Dir, Ids) + end, erpc:reqids_new(), Dirs), + worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds). + +worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> + receive + {'EXIT', Parent, Reason} -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + exit(Reason); + stop -> + %% clean up all the workers by being linked to them and dying + %% an unclean death. + unlink(Parent), + exit(shutdown) + after 0 -> + case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of + no_request -> + ReplyTo ! {revault, 'remote-seed', done}, + exit(normal); + no_response -> + worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); + {{response, Res}, Dir, NewIds} -> + ReplyTo ! {revault, 'remote-seed', {Dir, Res}}, + worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) + end + end. + +%%%%%%%%%%%%%%%%%%% +%%% EXTRA UTILS %%% +%%%%%%%%%%%%%%%%%%% + +%% Copied from revault_tls +make_selfsigned_cert(Dir, CertName) -> + check_openssl_vsn(), + + Key = filename:join(Dir, CertName ++ ".key"), + Cert = filename:join(Dir, CertName ++ ".crt"), + ok = filelib:ensure_dir(Cert), + Cmd = io_lib:format( + "openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes " + "-keyout '~ts' -out '~ts' -subj '/CN=example.org' " + "-addext 'subjectAltName=DNS:example.org,DNS:www.example.org,IP:127.0.0.1'", + [Key, Cert] % TODO: escape quotes + ), + os:cmd(Cmd). + +check_openssl_vsn() -> + Vsn = os:cmd("openssl version"), + VsnMatch = "(Open|Libre)SSL ([0-9]+)\\.([0-9]+)\\.([0-9]+)", + case re:run(Vsn, VsnMatch, [{capture, all_but_first, list}]) of + {match, [Type, Major, Minor, Patch]} -> + try + check_openssl_vsn(Type, list_to_integer(Major), + list_to_integer(Minor), + list_to_integer(Patch)) + catch + error:bad_vsn -> + error({openssl_vsn, Vsn}) + end; + _ -> + error({openssl_vsn, Vsn}) + end. + +%% Using OpenSSL >= 1.1.1 or LibreSSL >= 3.1.0 +check_openssl_vsn("Libre", A, B, _) when A > 3; + A == 3, B >= 1 -> + ok; +check_openssl_vsn("Open", A, B, C) when A > 1; + A == 1, B > 1; + A == 1, B == 1, C >= 1 -> + ok; +check_openssl_vsn(_, _, _, _) -> + error(bad_vsn). diff --git a/cli/revault_cli/src/revault_curses.erl b/cli/revault_cli/src/revault_curses.erl new file mode 100644 index 0000000..2c72398 --- /dev/null +++ b/cli/revault_cli/src/revault_curses.erl @@ -0,0 +1,745 @@ +-module(revault_curses). +-include("revault_cli.hrl"). +-include_lib("cecho/include/cecho.hrl"). +-export([start_link/1]). +-export([init/1]). +-export([parse_list/2, check_connect/2]). +-export([clear_status/1, set_status/2]). + +-type menu_key() :: atom(). +-type val_type() :: {type_name(), convertor(), validator()}. +-type type_name() :: node | string | list. +-type convertor() :: regex() | convertor_fun(). +-type regex() :: string(). +-type convertor_fun() :: fun((string(), state()) -> {ok, term(), state()} | {error, term()}). +-type validator() :: fun((state(), term()) -> ok | {error, term()}). +-type render_mode() :: raw | clip | {clip, pos()} | wrap. +-type pos() :: {y(), x()}. +-type y() :: non_neg_integer(). +-type x() :: non_neg_integer(). +-type max_lines() :: pos_integer(). +-type max_cols() :: pos_integer(). +-type line() :: string(). +-type lines() :: [line()]. +-type arg() :: #{name := type_name(), + label := string(), + help := string(), + type := val_type()}. +%% TODO: split internal state from callback state +-type state() :: map(). + +-callback menu_order() -> [menu_key(), ...]. +-callback menu_help(menu_key()) -> string(). +-callback args() -> #{menu_key() := arg()}. +-callback render_exec(menu_key(), max_lines(), max_cols(), state()) -> + {render_mode(), state(), lines()}. +-callback handle_exec({input, integer()} | term(), menu_key(), state()) -> {ok, state()}. + +%%%%%%%%%%%%%%%%% +%%% LIFECYCLE %%% +%%%%%%%%%%%%%%%%% +start_link(Module) -> + supervisor_bridge:start_link(?MODULE, Module). + +init(Module) -> + Pid = spawn_link(fun() -> main(Module) end), + {ok, Pid, Module}. + +main(Module) -> + setup(), + State = state(Module, #{}), + cecho:refresh(), + Pid = self(), + spawn_link(fun F() -> + Pid ! {input, cecho:getch()}, + F() + end), + loop(Module, select_menu(State, maps:get(hover_menu, State))). + +setup() -> + logger:remove_handler(default), + %% go in character-by-charcter mode + cecho:cbreak(), + %% don't show output + cecho:noecho(), + %% give keypad access + cecho:keypad(?ceSTDSCR, true), + %% don't wait on ESC keys + cecho:set_escdelay(25), + %% initial cursor position + cecho:move(1,1), + ok. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%% STATE AND DISPLAY MANAGEMENT %%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +state(Mod, Old) -> + Default = #{ + mode => menu, + hover_menu => hd(Mod:menu_order()), + menu => undefined, + peer => undefined, + dirs => undefined, + args => #{} + }, + Tmp0 = maps:merge(Default, Old), + %% Refresh the layout to show proper coordinates + Tmp1 = show_menu(Mod, Tmp0), + Tmp2 = show_action(Mod, Tmp1), + Tmp3 = show_exec(Mod, Tmp2), + Tmp4 = end_table(Mod, Tmp3), + cecho:refresh(), + Tmp4. + +show_menu(Mod, State) -> + #{menu := Chosen, hover_menu := Hover, mode := Mode} = State, + MenuOrder = Mod:menu_order(), + StringMenu = [atom_to_list(X) || X <- MenuOrder], + TopRow = ["╔═", + lists:join("═╤═", [lists:duplicate(string:length(X), "═") || X <- StringMenu]), + "═╗"], + MenuRow = ["║ ", lists:join(" │ ", [format_menu(X, Chosen) || X <- StringMenu]), " ║"], + BottomRow = ["╟─", + lists:join("─┴─", [lists:duplicate(string:length(X), "─") || X <- StringMenu]), + "─╢"], + {_, MenuMap, CoordMap} = lists:foldl( + fun(X, {N,M,C}) -> + XStr = atom_to_list(X), + {N+length(XStr)+3, + M#{X => {1,N}}, + C#{{1,N} => X}} + end, + {2, #{}, #{}}, + MenuOrder + ), + Width = string:length(MenuRow)-1, + str(0, 0, TopRow), + str(1, 0, MenuRow), + str(2, 0, BottomRow), + NewState = State#{menu_coords => {{0,0}, {2,Width}}, + menu_map => MenuMap, + menu_coord_map => CoordMap, + menu_init_pos => {1,2}}, + %% set cursor if in menu mode and show help for hovered item + case {Mode, Hover} of + {menu, Hover} -> + MoveTo = menu_pos(NewState, Hover), + mv(MoveTo), + set_status(NewState, Mod:menu_help(Hover)); + _ -> + NewState + end. + +show_action(_Mod, State = #{mode := Mode, + menu_coords := {_, End}}) when Mode == menu -> + State#{action_coords => {End, End}}; +show_action(Mod, State = #{mode := Mode, menu := Action, + menu_coords := {_, {MenuY, MaxX}}}) when Mode == action; + Mode == exec -> + MinY = MenuY, + %% TODO: clip lines that are too long + {ArgState, Args} = arg_output(State, Action, + maps:get(action_args, State, + maps:get(Action, Mod:args(), []))), + MaxY = lists:foldl(fun(Arg, Y) -> + #{line := {Label, Val}} = Arg, + Line = [Label, ": ", Val], + str(Y+1, 0, ["║ ", string:pad(Line, MaxX-3), " ║"]), + Y+1 + end, MinY, Args), + %% Ranges + {_, RevRanges} = lists:foldl(fun(Arg, {Y, Acc}) -> + #{line := {Label, _}} = Arg, + {Y+1, + [Arg#{range => {{Y+1, string:length(Label)+2+2}, {Y+1, MaxX-2}}} | Acc]} + end, {MinY, []}, Args), + Ranges = lists:reverse(RevRanges), + %% Set position + CurrentY = case cecho:getyx() of + {CurY, CurX} when CurY >= MinY, CurY =< MaxY, + CurX >= 2, CurX =< MaxX -> + CurY; + _ -> + %% Outside the box, move it to a known location + case Ranges of + [] -> + mv({MinY, 2}), + MinY; + [#{range := {First, _Last}}|_] -> + mv(First), + element(1, First) + end + end, + {ExtraLines, TmpState} = case Mode of + action -> + NthArg = CurrentY - MinY, + #{help := Help} = lists:nth(NthArg, Args), + {0, maybe_set_status(ArgState, Help)}; % this is the last section + _ -> + %% terminate table section + BottomRow = ["╟", lists:duplicate(MaxX-1, "─"), "╢"], + str(MaxY+1, 0, BottomRow), + {1, ArgState} + end, + TmpState#{action_coords => {{MinY,0}, {MaxY+ExtraLines,MaxX}}, + action_args => Ranges, + action_init_pos => {MinY, 2}}. + +show_exec(_Mod, State=#{mode := Mode, + action_coords := {_, {Y,X}}}) when Mode =/= exec -> + State#{exec_coords => {{Y,0},{Y,X}}}; +show_exec(Mod, State=#{mode := exec, + menu := Action, + action_coords := {_, {ActionY,MaxX}}}) -> + MinY = ActionY, + %% expect line-based output in a list + MaxLines = ?EXEC_LINES, + MaxCols = MaxX-4, + MaxY = MinY + MaxLines, + {RenderMode, ExecState, Lines} = Mod:render_exec(Action, MaxLines, MaxCols, State), + case RenderMode of + raw -> + render_raw(Lines, {MinY,0}, {MaxY, MaxX}); + wrap -> + render_raw(wrap(Lines, MaxLines, MaxCols), + {MinY,0}, {MaxY, MaxX}); + clip -> + render_raw(clip(Lines, {0,0}, MaxLines, MaxCols), + {MinY,0}, {MaxY, MaxX}); + {clip, Offsets} -> + render_raw(clip(Lines, Offsets, MaxLines, MaxCols), + {MinY,0}, {MaxY, MaxX}) + end, + ExecState#{exec_coords => {{MinY,0},{MaxY,MaxX}}}. + +end_table(_Mod, State=#{exec_coords := {_, {Y,X}}}) -> + str(Y+1, 0, ["╚", lists:duplicate(X-1, "═") ,"╝"]), + str(Y+2, 0, " ╰─ "), + %% Clear status area and render status if present + {StatusY, StatusX} = {Y+2, 5}, + %% Clear the entire status line + {MaxY,MaxX} = cecho:getmaxyx(), + [str(LY, StatusX, lists:duplicate(MaxX-StatusX, $\s)) || LY <- lists:seq(StatusY,MaxY)], + %% Render status message if present + case State of + #{status_message := StatusMsg} -> + str(StatusY, StatusX, StatusMsg); + _ -> + ok + end, + State#{status_coords => {{Y+1,0}, {StatusY,StatusX}}, + status_init_pos => {StatusY,StatusX}}. + +render_raw(Lines, {MinY, MinX}, {MaxY, MaxX}) -> + LinesY = lists:foldl(fun(Line, Y) -> + str(Y+1, MinX, ["║ ", string:pad(Line, MaxX-MinX-3), " ║"]), + Y+1 + end, MinY, Lines), + [str(LineY, MinX, ["║", lists:duplicate(MaxX-MinX-1, " "), "║"]) + || LineY <- lists:seq(LinesY+1, MaxY)]. + +select_menu(State, Menu) -> + mv(menu_pos(State, Menu)), + State#{hover_menu => Menu}. + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +%%% STATE AND DISPLAY HELPERS %%% +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +format_menu(X, Chosen) -> + case atom_to_list(Chosen) of + X -> string:uppercase(X); + _ -> X + end. + +menu_at(#{menu_coord_map := CoordMap}, Coord) -> + #{Coord := Menu} = CoordMap, + Menu. + +menu_pos(#{menu_map := M}, Menu) -> + #{Menu := Coord} = M, + Coord. + +enter_menu(State, Menu) -> + State#{mode => action, + menu => Menu}. + +set_status(State, Str) -> + State#{status_message => Str}. + +maybe_set_status(State=#{status_message := _}, _) -> State; +maybe_set_status(State, Str) -> set_status(State, Str). + +clear_status(State) -> + maps:without([status_message], State). + +wrap(Str, Lines, Width) -> + wrap(Str, 0, Width, 0, Lines, [[]]). + +wrap(_Str, Width, Width, Lines, Lines, Acc) -> + lists:reverse(Acc); +wrap(Str, Width, Width, Ln, Lines, [L|Acc]) -> + wrap(Str, 0, Width, Ln+1, Lines, [[],lists:reverse(L)|Acc]); +wrap(Str, W, Width, Ln, Lines, [L|Acc]) -> + case string:next_grapheme(Str) of + [Brk|Rest] when Brk == $\n; Brk == "\r\n" -> + wrap(Rest, 0, Width, Ln+1, Lines, [[], lists:reverse(L)|Acc]); + [C|Rest] -> + wrap(Rest, W+1, Width, Ln, Lines, [[C|L]|Acc]); + [] -> + lists:reverse([lists:reverse(L)|Acc]) + end. + +clip(Lines, {OffY, OffX}, MaxLines, MaxCols) -> + [string:slice(S, OffX, MaxCols) + || S <- lists:sublist(Lines, OffY+1, MaxLines)]. + +%%%%%%%%%%%%%%%%%%%% +%%% ARG HANDLING %%% +%%%%%%%%%%%%%%%%%%%% + +parse_list(String, State) -> + try + %% drop surrounding whitespace and split on commas + S = string:trim(String, both), + L = re:split(S, "[\\s]*,[\\s]*", [{return, binary}, unicode]), + %% ignore empty results (<<>>) in returned value + {ok, [B || B <- L], State} + catch + _:_ -> {error, invalid, State} + end. + +check_connect(_State, Node) -> + case connect_nonblocking(Node) of + ok -> ok; + timeout -> {error, timeout}; + _ -> {error, connection_failure} + end. + +connect_nonblocking(Node) -> + timeout_call(?MAX_VALIDATION_DELAY, fun() -> connect(Node) end). + +connect(Node) -> + case net_kernel:connect_node(Node) of + ignored -> {error, no_dist}; + false -> {error, connection_failed}; + true -> ok + end. + +arg_output(State, Action, Args) -> + arg_output(State, Action, Args, []). + +arg_output(State, _, [], Acc) -> + {State, lists:reverse(Acc)}; +arg_output(State, Action, [Arg|Args], Acc) when not is_map_key(val, Arg) -> + {NewState, NewArg} = arg_init(State, Action, Arg), + arg_output(NewState, Action, [NewArg|Args], Acc); +arg_output(State, Action, [Arg=#{unparsed := Unparsed}|Args], Acc) -> + %% refresh data of pre-parsed elements. + %% with the new value in place, apply the transformation to its internal + %% format for further commands + Ret = parse_arg(State, Action, Arg, Unparsed), + %% Store it all! + case Ret of + {ok, Val, NewState} -> + arg_output(NewState, Action, + [maps:without([line, unparsed], Arg#{val => Val}) | Args], Acc); + {error, _Reason, NewState} -> + %% TODO: update status? + arg_output(NewState, Action, [maps:without([unparsed], Arg)|Args], Acc) + end; +arg_output(State, Action, [Arg=#{line := _} | Args], Acc) -> + arg_output(State, Action, Args, [Arg|Acc]); +arg_output(State, Action, [#{type := {node, _, Validator}, label := Label, val := NodeVal}=Arg|Args], Acc) -> + Status = case Validator(State, NodeVal) of + ok -> "ok"; + {error, partial_success} -> "?!"; + {error, timeout} -> "??"; + _ -> "!!" + end, + Line = {[Label, " (", Status, ")"], atom_to_list(NodeVal)}, + arg_output(State#{local_node => NodeVal}, Action, + [Arg#{line => Line}|Args], Acc); +arg_output(State, Action, [#{name := dirs, type := {list, _, _}, label := Label, val := DirList}=Arg|Args], Acc) -> + Line = {Label, lists:join(", ", DirList)}, + arg_output(State#{dir_list => DirList}, Action, + [Arg#{line => Line}|Args], Acc); +arg_output(State, Action, [#{type := {list, _, _}, label := Label, val := List}=Arg|Args], Acc) -> + Line = {Label, lists:join(", ", List)}, + arg_output(State, Action, [Arg#{line => Line}|Args], Acc); +arg_output(State, Action, [#{type := {string, _, _}, label := Label, val := Val}=Arg|Args], Acc) -> + Line = {Label, Val}, + arg_output(State, Action, [Arg#{line => Line}|Args], Acc); +arg_output(State, Action, [#{type := Unsupported}=Arg|Args], Acc) -> + #{label := Label} = Arg, + Line = {io_lib:format("[Unsupported] ~ts", [Label]), + io_lib:format("~p", [Unsupported])}, + arg_output(State, Action, [Arg#{line => Line}|Args], Acc). + + +arg_init(State, _Action, Arg = #{type := {node, _, _}, default := Default}) -> + Val = maps:get(local_node, State, Default), + {State#{local_node => Val}, Arg#{val => Val}}; +arg_init(State, _Action, Arg = #{name := dirs, type := {list, _, _}, default := F}) -> + Default = F(State), + {State#{dir_list => Default}, Arg#{val => Default}}; +arg_init(State, _Action, Arg = #{type := {list, _, _}, default := F}) -> + {State, Arg#{val => F(State)}}; +arg_init(State, _Action, Arg = #{type := {string, _, _}, default := X}) -> + Default = if is_function(X, 1) -> X(State); + is_function(X) -> error(bad_arity); + true -> X + end, + {State, Arg#{val => Default}}; +arg_init(State, _Action, Arg = #{type := Unsupported}) -> + {State, Arg#{val => {error, Unsupported}}}. + +parse_arg(State, _Action, #{type := TypeInfo}, Unparsed) -> + case TypeInfo of + {T, F, _Validation} when is_function(F) -> + parse_with_fun(T, F, Unparsed, State); + {T, Regex, _Validation} when is_list(Regex); is_binary(Regex) -> + F = fun(String, St) -> parse_regex(Regex, String, St) end, + parse_with_fun(T, F, Unparsed, State) + end. + +parse_regex(Re, String, State) -> + case re:run(String, Re, [{capture, first, binary}, unicode]) of + {match, [Str]} -> {ok, Str, State}; + nomatch -> {error, invalid, State} + end. + +parse_with_fun(node, F, Str, State) -> + maybe + {ok, NewStr, NewState} ?= F(Str, State), + Node = binary_to_atom(NewStr), + {ok, Node, NewState} + end; +parse_with_fun(_Type, F, Str, State) -> + F(Str, State). + +%% Internal use functions using the above material +%% but used to check all arguments fit and can be converted +%% properly. +validate_args(State, Action, Args) -> + %% Validate all the arguments + {Errors, Status} = lists:foldl( + fun(Arg = #{line := {_Label, Str}}, {Acc, S}) -> + case parse_arg(State, Action, Arg, Str) of + {ok, _, _} -> {Acc, S}; + {error, Reason, _} -> {[{Arg, Reason}|Acc], error} + end + end, + {[], ok}, + Args + ), + case Status of + error -> + {error, Errors}; + ok -> + {_Valid, Invalid} = convert_args(State, Args), + case Invalid of + [] -> + ok; + Invalid -> + {error, Invalid} + end + end. + +convert_args(State, Args) -> + lists:foldl( + fun(Arg = #{val := Val, type := {_,_,F}}, {V,I}) -> + case F(State, Val) of + ok -> {[Arg|V], I}; + {error, Reason} -> {V, [{Arg, Reason}]} + end + end, + {[],[]}, + Args + ). + +%%%%%%%%%%%%%%%%%%%%% +%%% MAIN TUI LOOP %%% +%%%%%%%%%%%%%%%%%%%%% +loop(Mod, OldState) -> + State = #{mode := Mode} = state(Mod, OldState), + case Mode of + menu -> + receive + {input, Input} -> + {ok, NewState} = handle_menu(Mod, {input, Input}, State), + loop(Mod, NewState) + end; + action -> + #{menu := Action} = State, + receive + {input, Input} -> + {ok, NewState} = handle_action({input, Input}, Action, clear_status(State)), + loop(Mod, NewState) + end; + exec -> + #{menu := Action} = State, + receive + {input, Input} -> + {ok, NewState} = handle_exec(Mod, {input, Input}, Action, State), + loop(Mod, NewState); + {revault, Action, _} = Event -> + {ok, NewState} = handle_exec(Mod, Event, Action, State), + loop(Mod, NewState) + end + end. + + +handle_menu(Mod, {input, Key}, TmpState) -> + Pos = cecho:getyx(), + case Key of + ?ceKEY_RIGHT -> + NewMenu = next(menu_at(TmpState, Pos), Mod:menu_order()), + State = select_menu(TmpState, NewMenu), + {ok, State}; + ?ceKEY_LEFT -> + NewMenu = prev(menu_at(TmpState, Pos), Mod:menu_order()), + State = select_menu(TmpState, NewMenu), + mv_by({0,0}), + {ok, State}; + $\n -> + Menu = menu_at(TmpState, Pos), + State = clear_status(enter_menu(TmpState, Menu)), + {ok, State}; + UnknownChar -> + State = set_status( + TmpState, + io_lib:format("Unknown menu character: ~w", [UnknownChar]) + ), + {ok, State} + end. + +%% A little TUI editors for parameters. +handle_action({input, ?ceKEY_ESC}, _Action, TmpState = #{menu := _Menu}) -> + %% exit the menu + TmpState2 = TmpState#{mode => menu, menu => undefined}, + %% clear up the arg list + %% TODO: cache by action? + State = maps:without([action_args], TmpState2), + cecho:erase(), + {ok, State}; +handle_action({input, ?ceKEY_DOWN}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + After = lists:dropwhile(fun(#{range := {_, {MaxY,_}}}) -> Y >= MaxY end, Args), + case After of + [#{range := {Pos, _}}|_] -> mv(Pos); + _ -> ok + end, + {ok, State}; +handle_action({input, ?ceKEY_UP}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + Before = lists:takewhile(fun(#{range := {{MinY,_}, _}}) -> Y > MinY end, Args), + case lists:reverse(Before) of + [#{range := {Pos, _}}|_] -> mv(Pos); + _ -> ok + end, + {ok, State}; +handle_action({input, ?ceKEY_LEFT}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, #{range := {{_,MinX},_}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + X > MinX andalso mv_by({0,-1}), + {ok, State}; +handle_action({input, ?ceKEY_RIGHT}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, #{range := {{_,MinX}, {_,MaxX}}, + line := {_, Str}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + X < MaxX andalso X < MinX+string:length(Str) andalso mv_by({0,1}), + {ok, State}; +handle_action({input, ?KEY_CTRLA}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + {value, #{range := {{_,MinX},_}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + mv({Y, MinX}), + {ok, State}; +handle_action({input, ?KEY_CTRLE}, _Action, State = #{action_args := Args}) -> + {Y,_} = cecho:getyx(), + {value, #{range := {{_,MinX},_}, + line := {_, Str}}} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + mv({Y,MinX+string:length(Str)}), + {ok, State}; +handle_action({input, ?KEY_BACKSPACE}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X > MinX of + true -> % can go back + Pre = string:slice(Str, 0, (X-MinX)-1), + Post = string:slice(Str, X-MinX), + Edited = [Pre,Post], + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + mv_by({0,-1}), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, ?ceKEY_DEL}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X >= MinX of + true -> % can go back + Pre = string:slice(Str, 0, X-MinX), + Post = string:slice(Str, (X-MinX)+1), + Edited = [Pre,Post], + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, ?KEY_CTRLD}, _Action, State = #{action_args := Args}) -> + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X >= MinX of + true -> % can go back + Edited = string:slice(Str, 0, X-MinX), + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, Char}, _Action, State = #{action_args := Args}) when ?KEY_TEXT_RANGE(Char) -> + %% text input! + {Y,X} = cecho:getyx(), + {value, Arg} = lists:search( + fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, + Args + ), + #{range := {{_,MinX},{_,MaxX}}, + line := {Label,Str}} = Arg, + NewStr = case X < MaxX andalso X >= MinX + andalso X =< MinX+string:length(Str) of + true -> + Pre = string:slice(Str, 0, X-MinX), + Post = string:slice(Str, (X-MinX)), + Edited = string:slice([Pre, Char, Post], 0, MaxX-MinX), + str(Y, MinX, string:pad("", MaxX-MinX)), + str(Y, MinX, Edited), + mv_by({0,1}), + Edited; + false -> + Str + end, + NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, + unparsed => NewStr}), + {ok, State#{action_args=>NewArgs}}; +handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> + %% revalidate all values in all ranges; if any error + %% is found, show it in the status line. + %% if none are found, extract as clean options, and + %% switch to execution mode. + case validate_args(TmpState, Action, Args) of + ok -> + State = set_status(TmpState, "ok."), + {ok, State#{mode => exec, exec_args => Args}}; + {error, [{#{line := {Label, _}}, Reason}|_]} -> + State = set_status( + TmpState, + io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) + ), + {ok, State} + end; +handle_action({input, UnknownChar}, Action, TmpState) -> + State = set_status( + TmpState, + io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) + ), + {ok, State}. + +handle_exec(Mod, Event, Action, State) -> + Mod:handle_exec(Event, Action, State). + +%%%%%%%%%%%%%%%%%%%%%%% +%%% NCURSES HELPERS %%% +%%%%%%%%%%%%%%%%%%%%%%% +str(Y, X, Str) -> + {OrigY, OrigX} = cecho:getyx(), + %% cecho expects a lists of bytes, so we gotta do some fun converting + cecho:mvaddstr(Y, X, binary_to_list(unicode:characters_to_binary(Str))), + cecho:move(OrigY, OrigX). + +mv_by({OffsetY, OffsetX}) -> + {CY, CX} = cecho:getyx(), + cecho:move(CY+OffsetY, CX+OffsetX). + +mv({Y,X}) -> + cecho:move(Y, X). + +%%%%%%%%%%%%%%%%%%%%%%%%%% +%%% FUNCTIONAL HELPERS %%% +%%%%%%%%%%%%%%%%%%%%%%%%%% +prev(K, L) -> next(K, lists:reverse(L)). + +next(_, [N]) -> N; +next(K, [K,N|_]) -> N; +next(K, [_|T]) -> next(K, T). + +replace([H|T], H, R) -> [R|T]; +replace([H|T], S, R) -> [H|replace(T, S, R)]. + +%% small helper that defers a blocking call that can be long +%% to another process, such that the validation step can have a +%% ceiling for how long it takes before returning a value. +%% If the process times out, it is killed brutally. +timeout_call(Timeout, Fun) -> + P = self(), + R = make_ref(), + {Pid, Ref} = spawn_monitor(fun() -> + Res = Fun(), + P ! {R, Res} + end), + receive + {R, Res} -> + erlang:demonitor(R, [flush]), + Res; + {'DOWN', Ref, _, _, _} -> + {error, connection_attempt_failed} + after Timeout -> + erlang:exit(Pid, kill), + receive + {'DOWN', Ref, _, _, _} -> + timeout; + {R, Res} -> + erlang:demonitor(R, [flush]), + Res + end + end. diff --git a/config/cli.args.src b/config/cli.args.src new file mode 100644 index 0000000..05028b0 --- /dev/null +++ b/config/cli.args.src @@ -0,0 +1,3 @@ +-noinput +-name ncurses_cli +-setcookie revault_cookie diff --git a/config/cli.sys.config b/config/cli.sys.config new file mode 100644 index 0000000..57afcca --- /dev/null +++ b/config/cli.sys.config @@ -0,0 +1 @@ +[]. diff --git a/rebar.config b/rebar.config index af5c29b..d6f8693 100644 --- a/rebar.config +++ b/rebar.config @@ -75,7 +75,12 @@ ]} ]}, {ncurses, [ - {deps, [{cecho, {git, "https://github.com/ferd/cecho.git", {branch, "master"}}}]} + {project_app_dirs, ["apps/*", "lib/*", "cli/*", "."]}, + {deps, [{cecho, {git, "https://github.com/ferd/cecho.git", {branch, "master"}}}]}, + {relx, [{release, {cli, "0.1.0"}, + [revault_cli, cecho]}, + {sys_config, "./config/cli.sys.config"}, + {vm_args_src, "./config/cli.args.src"}]} ]}, {debug, [ %% generate debug traces in gen_* processes From af2aa56cff18fe8e2a03f71114ab30961bb50c13 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 5 Jul 2025 16:39:22 -0400 Subject: [PATCH 23/27] WIP: further split TUI logic --- cli/revault_cli/src/revault_cli_mod.erl | 125 +++++++++--------------- cli/revault_cli/src/revault_curses.erl | 48 +++++++-- 2 files changed, 87 insertions(+), 86 deletions(-) diff --git a/cli/revault_cli/src/revault_cli_mod.erl b/cli/revault_cli/src/revault_cli_mod.erl index 831ea45..95a92ef 100644 --- a/cli/revault_cli/src/revault_cli_mod.erl +++ b/cli/revault_cli/src/revault_cli_mod.erl @@ -6,7 +6,7 @@ -define(DEFAULT_NODE, list_to_atom("revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@"))))). -export([menu_order/0, menu_help/1, args/0, - render_exec/4, handle_exec/3]). + render_exec/4, handle_exec/4]). %%%%%%%%%%%%%%%%% %%% CALLBACKS %%% @@ -162,36 +162,33 @@ render_exec(Action, _MaxLines, _MaxCols, State) -> {clip, State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. -handle_exec({input, ?ceKEY_ESC}, _Action, TmpState) -> +handle_exec(input, ?ceKEY_ESC, _Action, State) -> %% TODO: clean up workers if any - %% clear up the arg list and status messages - State = revault_curses:clear_status(maps:without([exec_state], TmpState#{mode => action})), - cecho:erase(), - {ok, State}; + {done, State}; %% List exec -handle_exec({input, ?ceKEY_DOWN}, list, State = #{exec_state:=ES}) -> +handle_exec(input, ?ceKEY_DOWN, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), {ok, State#{exec_state => ES#{offset => {Y+1, X}}}}; -handle_exec({input, ?ceKEY_UP}, list, State = #{exec_state:=ES}) -> +handle_exec(input, ?ceKEY_UP, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), {ok, State#{exec_state => ES#{offset => {max(0,Y-1), X}}}}; -handle_exec({input, ?ceKEY_RIGHT}, list, State = #{exec_state:=ES}) -> +handle_exec(input, ?ceKEY_RIGHT, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), {ok, State#{exec_state => ES#{offset => {Y, X+1}}}}; -handle_exec({input, ?ceKEY_LEFT}, list, State = #{exec_state:=ES}) -> +handle_exec(input, ?ceKEY_LEFT, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), {ok, State#{exec_state => ES#{offset => {Y, max(0,X-1)}}}}; -handle_exec({input, ?ceKEY_PGDOWN}, list, State = #{exec_state:=ES}) -> +handle_exec(input, ?ceKEY_PGDOWN, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), Shift = ?EXEC_LINES-1, {ok, State#{exec_state => ES#{offset => {Y+Shift, X}}}}; -handle_exec({input, ?ceKEY_PGUP}, list, State = #{exec_state:=ES}) -> +handle_exec(input, ?ceKEY_PGUP, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), Shift = ?EXEC_LINES-1, {ok, State#{exec_state => ES#{offset => {max(0,Y-Shift), X}}}}; %% TODO: ctrlA, ctrlE %% Scan exec -handle_exec({revault, scan, done}, scan, State=#{exec_state:=ES}) -> +handle_exec(event, {revault, scan, done}, scan, State=#{exec_state:=ES}) -> %% unset the workers case maps:get(worker, ES, undefined) of undefined -> @@ -211,17 +208,17 @@ handle_exec({revault, scan, done}, scan, State=#{exec_state:=ES}) -> end end, {ok, State}; -handle_exec({revault, scan, {Dir, Status}}, scan, State=#{exec_state:=ES}) -> +handle_exec(event, {revault, scan, {Dir, Status}}, scan, State=#{exec_state:=ES}) -> #{dirs := Statuses} = ES, {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -handle_exec({input, ?KEY_ENTER}, scan, State) -> +handle_exec(input, ?KEY_ENTER, scan, State) -> %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {revault, scan, done}, - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, + revault_curses:send_event(self(), {revault, scan, done}), + revault_curses:send_input(self(), ?ceKEY_ESC), + revault_curses:send_input(self(), ?KEY_ENTER), {ok, State}; %% Sync exec -handle_exec({revault, sync, done}, sync, State=#{exec_state:=ES}) -> +handle_exec(event, {revault, sync, done}, sync, State=#{exec_state:=ES}) -> %% unset the workers case maps:get(worker, ES, undefined) of undefined -> @@ -241,36 +238,36 @@ handle_exec({revault, sync, done}, sync, State=#{exec_state:=ES}) -> end end, {ok, State}; -handle_exec({revault, sync, {Dir, Status}}, sync, State=#{exec_state:=ES}) -> +handle_exec(event, {revault, sync, {Dir, Status}}, sync, State=#{exec_state:=ES}) -> #{dirs := Statuses} = ES, {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -handle_exec({input, ?KEY_ENTER}, sync, State) -> +handle_exec(input, ?KEY_ENTER, sync, State) -> %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {revault, scan, done}, - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, + revault_curses:send_event(self(), {revault, sync, done}), + revault_curses:send_input(self(), ?ceKEY_ESC), + revault_curses:send_input(self(), ?KEY_ENTER), {ok, State}; %% Status -handle_exec({revault, status, done}, status, State) -> +handle_exec(event, {revault, status, done}, status, State) -> {ok, State}; -handle_exec({revault, status, {ok, Val}}, status, State=#{exec_state:=ES}) -> +handle_exec(event, {revault, status, {ok, Val}}, status, State=#{exec_state:=ES}) -> {ok, State#{exec_state => ES#{status => Val}}}; -handle_exec({input, ?KEY_ENTER}, status, State) -> +handle_exec(input, ?KEY_ENTER, status, State) -> %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {revault, status, done}, - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, + revault_curses:send_event(self(), {revault, status, done}), + revault_curses:send_input(self(), ?ceKEY_ESC), + revault_curses:send_input(self(), ?KEY_ENTER), {ok, State}; %% Generate-Keys -handle_exec({revault, 'generate-keys', {ok, Val}}, 'generate-keys', State=#{exec_state:=ES}) -> +handle_exec(event, {revault, 'generate-keys', {ok, Val}}, 'generate-keys', State=#{exec_state:=ES}) -> {ok, State#{exec_state => ES#{status => Val}}}; -handle_exec({input, ?KEY_ENTER}, 'generate-keys', State) -> +handle_exec(input, ?KEY_ENTER, 'generate-keys', State) -> %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, + revault_curses:send_input(self(), ?ceKEY_ESC), + revault_curses:send_input(self(), ?KEY_ENTER), {ok, State}; %% Seed exec -handle_exec({revault, seed, done}, seed, State=#{exec_state:=ES}) -> +handle_exec(event, {revault, seed, done}, seed, State=#{exec_state:=ES}) -> %% unset the workers case maps:get(worker, ES, undefined) of undefined -> @@ -290,18 +287,11 @@ handle_exec({revault, seed, done}, seed, State=#{exec_state:=ES}) -> end end, {ok, State}; -handle_exec({revault, seed, {Dir, Status}}, seed, State=#{exec_state:=ES}) -> +handle_exec(event,{revault, seed, {Dir, Status}}, seed, State=#{exec_state:=ES}) -> #{dirs := Statuses} = ES, {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -% do not endlessly re-seed? -% handle_exec({input, ?KEY_ENTER}, seed, State) -> -% %% Do a refresh by exiting the menu and re-entering again. Quite hacky. -% self() ! {revault, seed, done}, -% self() ! {input, ?ceKEY_ESC}, -% self() ! {input, ?KEY_ENTER}, -% {ok, State}; %% remote-seed exec -handle_exec({revault, 'remote-seed', done}, 'remote-seed', State=#{exec_state:=ES}) -> +handle_exec(event,{revault, 'remote-seed', done}, 'remote-seed', State=#{exec_state:=ES}) -> %% unset the workers case maps:get(worker, ES, undefined) of undefined -> @@ -321,29 +311,10 @@ handle_exec({revault, 'remote-seed', done}, 'remote-seed', State=#{exec_state:=E end end, {ok, State}; -handle_exec({revault, 'remote-seed', {Dir, Status}}, 'remote-seed', State=#{exec_state:=ES}) -> +handle_exec(event,{revault, 'remote-seed', {Dir, Status}}, 'remote-seed', State=#{exec_state:=ES}) -> #{dirs := Statuses} = ES, file:write_file("/tmp/dbg", io_lib:format("~p~n", [{Dir, Status}])), - {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -%% Generic exec -handle_exec({input, UnknownChar}, Action, TmpState) -> - State = revault_curses:set_status( - TmpState, - io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) - ), - {ok, State}; -handle_exec({revault, EventAct, Event}, Act, TmpState) -> - State = revault_curses:set_status( - TmpState, - io_lib:format("Got unexpected ~p event in ~p: ~p", [EventAct, Act, Event]) - ), - {ok, State}; -handle_exec(Msg, Action, TmpState) -> - State = revault_curses:set_status( - TmpState, - io_lib:format("Got unexpected message in ~p: ~p", [Action, Msg]) - ), - {ok, State}. + {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}. %%%%%%%%%%%%%%%%%%%% @@ -574,12 +545,12 @@ worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> after 0 -> case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of no_request -> - ReplyTo ! {revault, scan, done}, + revault_curses:send_event(ReplyTo, {revault, scan, done}), exit(normal); no_response -> worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds); {{response, Res}, Dir, NewIds} -> - ReplyTo ! {revault, scan, {Dir, Res}}, + revault_curses:send_event(ReplyTo, {revault, scan, {Dir, Res}}), worker_scan_loop(Parent, ReplyTo, Node, Dirs, NewIds) end end. @@ -611,7 +582,7 @@ worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> after 0 -> case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of no_request -> - ReplyTo ! {revault, sync, done}, + revault_curses:send_event(ReplyTo, {revault, sync, done}), exit(normal); no_response -> worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); @@ -620,7 +591,7 @@ worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> ok -> scanned; Other -> Other end, - ReplyTo ! {revault, sync, {Dir, Status}}, + revault_curses:send_event(ReplyTo, {revault, sync, {Dir, Status}}), NewIds = erpc:send_request( Node, revault_fsm, sync, [Dir, Peer], @@ -633,7 +604,7 @@ worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> ok -> synced; Other -> Other end, - ReplyTo ! {revault, sync, {Dir, Status}}, + revault_curses:send_event(ReplyTo, {revault, sync, {Dir, Status}}), worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) end end. @@ -655,12 +626,12 @@ worker_status_loop(Parent, ReplyTo, ReqIds) -> after 0 -> case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of no_request -> - ReplyTo ! {revault, status, done}, + revault_curses:send_event(ReplyTo, {revault, status, done}), exit(normal); no_response -> worker_status_loop(Parent, ReplyTo, ReqIds); {{response, Res}, status, NewIds} -> - ReplyTo ! {revault, status, {ok, Res}}, + revault_curses:send_event(ReplyTo, {revault, status, {ok, Res}}), worker_status_loop(Parent, ReplyTo, NewIds) end end. @@ -671,7 +642,7 @@ worker_generate_keys(Parent, ReplyTo, Path, File) -> unicode:characters_to_list(File)), %% we actually don't have a loop, everything is local %% and has already be run, so we just wait for a shutdown signal. - ReplyTo ! {revault, 'generate-keys', {ok, Res}}, + revault_curses:send_event(ReplyTo, {revault, 'generate-keys', {ok, Res}}), receive {'EXIT', Parent, Reason} -> exit(Reason); @@ -707,12 +678,12 @@ worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> after 0 -> case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of no_request -> - ReplyTo ! {revault, seed, done}, + revault_curses:send_event(ReplyTo, {revault, seed, done}), exit(normal); no_response -> worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds); {{response, Res}, Dir, NewIds} -> - ReplyTo ! {revault, seed, {Dir, Res}}, + revault_curses:send_event(ReplyTo, {revault, seed, {Dir, Res}}), worker_seed_loop(Parent, ReplyTo, Node, Dirs, NewIds) end end. @@ -744,12 +715,12 @@ worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> after 0 -> case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of no_request -> - ReplyTo ! {revault, 'remote-seed', done}, + revault_curses:send_event(ReplyTo, {revault, 'remote-seed', done}), exit(normal); no_response -> worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); {{response, Res}, Dir, NewIds} -> - ReplyTo ! {revault, 'remote-seed', {Dir, Res}}, + revault_curses:send_event(ReplyTo, {revault, 'remote-seed', {Dir, Res}}), worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) end end. diff --git a/cli/revault_cli/src/revault_curses.erl b/cli/revault_cli/src/revault_curses.erl index 2c72398..c1dc232 100644 --- a/cli/revault_cli/src/revault_curses.erl +++ b/cli/revault_cli/src/revault_curses.erl @@ -1,7 +1,7 @@ -module(revault_curses). -include("revault_cli.hrl"). -include_lib("cecho/include/cecho.hrl"). --export([start_link/1]). +-export([start_link/1, send_input/2, send_event/2]). -export([init/1]). -export([parse_list/2, check_connect/2]). -export([clear_status/1, set_status/2]). @@ -33,7 +33,8 @@ -callback args() -> #{menu_key() := arg()}. -callback render_exec(menu_key(), max_lines(), max_cols(), state()) -> {render_mode(), state(), lines()}. --callback handle_exec({input, integer()} | term(), menu_key(), state()) -> {ok, state()}. +-callback handle_exec(input, integer(), menu_key(), state()) -> {ok | done, state()}; + (event, term(), menu_key(), state()) -> {ok | done, state()}. %%%%%%%%%%%%%%%%% %%% LIFECYCLE %%% @@ -41,6 +42,12 @@ start_link(Module) -> supervisor_bridge:start_link(?MODULE, Module). +send_input(Pid, Char) -> + Pid ! {input, Char}. + +send_event(Pid, Event) -> + Pid ! {event, Event}. + init(Module) -> Pid = spawn_link(fun() -> main(Module) end), {ok, Pid, Module}. @@ -478,12 +485,17 @@ loop(Mod, OldState) -> end; exec -> #{menu := Action} = State, - receive - {input, Input} -> - {ok, NewState} = handle_exec(Mod, {input, Input}, Action, State), + Msg = receive + {input, Input} -> {input, Input}; + {event, Other} -> {event, Other} + end, + case handle_exec(Mod, Msg, Action, State) of + {ok, NewState} -> loop(Mod, NewState); - {revault, Action, _} = Event -> - {ok, NewState} = handle_exec(Mod, Event, Action, State), + {done, TmpState} -> + %% clear up the arg list and status messages + NewState = revault_curses:clear_status(maps:without([exec_state], TmpState#{mode => action})), + cecho:erase(), loop(Mod, NewState) end end. @@ -685,8 +697,26 @@ handle_action({input, UnknownChar}, Action, TmpState) -> ), {ok, State}. -handle_exec(Mod, Event, Action, State) -> - Mod:handle_exec(Event, Action, State). +handle_exec(Mod, {Type, Msg}, Action, State) -> + try + Mod:handle_exec(Type, Msg, Action, State) + catch + error:function_clause:Stack -> + case Stack of + [{Mod, handle_exec, _, _}|_] -> + Status = case {Type, Msg} of + {input, Char} -> + io_lib:format("Unknown character in ~p: ~w", [Action, Char]); + {event, _Event} -> + io_lib:format("Got unexpected event in ~p: ~p", [Action, Msg]); + _ -> + io_lib:format("Got unexpected message in ~p: ~p", [Action, Msg]) + end, + {ok, set_status(State, Status)}; + _ -> + erlang:raise(error, function_clause, Stack) + end + end. %%%%%%%%%%%%%%%%%%%%%%% %%% NCURSES HELPERS %%% From 080180aef46fb110d8f522e448ab1996a7ebf4a6 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 5 Jul 2025 17:10:51 -0400 Subject: [PATCH 24/27] WIP: clean-up args --- cli/revault_cli/src/revault_cli_mod.erl | 41 +++++++++++++------------ cli/revault_cli/src/revault_curses.erl | 32 +++++++++++++------ 2 files changed, 45 insertions(+), 28 deletions(-) diff --git a/cli/revault_cli/src/revault_cli_mod.erl b/cli/revault_cli/src/revault_cli_mod.erl index 95a92ef..60eaa7e 100644 --- a/cli/revault_cli/src/revault_cli_mod.erl +++ b/cli/revault_cli/src/revault_cli_mod.erl @@ -6,7 +6,7 @@ -define(DEFAULT_NODE, list_to_atom("revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@"))))). -export([menu_order/0, menu_help/1, args/0, - render_exec/4, handle_exec/4]). + init/0, render_exec/5, handle_exec/4]). %%%%%%%%%%%%%%%%% %%% CALLBACKS %%% @@ -86,6 +86,11 @@ args() -> ] }. +init() -> + #{}. + +render_exec(Action, Args, MaxLines, MaxCols, State) -> + render_exec(Action, MaxLines, MaxCols, State#{exec_args => Args}). render_exec(list, _MaxLines, _MaxCols, State) -> NewState = ensure_exec_state(list, State), @@ -164,7 +169,7 @@ render_exec(Action, _MaxLines, _MaxCols, State) -> handle_exec(input, ?ceKEY_ESC, _Action, State) -> %% TODO: clean up workers if any - {done, State}; + {done, maps:without([exec_state], State)}; %% List exec handle_exec(input, ?ceKEY_DOWN, list, State = #{exec_state:=ES}) -> {Y,X} = maps:get(offset, ES, {0, 0}), @@ -417,8 +422,7 @@ ensure_exec_state(list, State) -> case State of #{exec_state := #{path := _, config := _, offset := _}} -> State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + #{exec_args := #{node := Node}} -> {ok, P, C} = erpc:call(Node, maestro_loader, current, []), State#{exec_state => #{path => P, config => C, offset => {0,0}}} end; @@ -427,8 +431,8 @@ ensure_exec_state(scan, State) -> #{exec_state := #{worker := _, dirs := _}} -> State; #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + #{node := Node, + dirs := Dirs} = Args, %% TODO: replace with an alias P = start_worker(self(), {scan, Node, Dirs}), DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), @@ -439,9 +443,9 @@ ensure_exec_state(sync, State) -> #{exec_state := #{worker := _, peer := _, dirs := _}} -> State; #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + #{node := Node, + peer := P, + dirs := Dirs} = Args, %% TODO: replace with an alias W = start_worker(self(), {sync, Node, P, Dirs}), DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), @@ -451,8 +455,7 @@ ensure_exec_state(status, State) -> case State of #{exec_state := #{worker := _, status := _}} -> State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), + #{exec_args := #{node := Node}} -> %% TODO: replace with an alias P = start_worker(self(), {status, Node}), State#{exec_state => #{worker => P, status => undefined}} @@ -463,8 +466,8 @@ ensure_exec_state('generate-keys', State) -> %% Do wrapping of the status line State; #{exec_args := Args} -> - {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), - {value, #{val := File}} = lists:search(fun(#{name := N}) -> N == certname end, Args), + #{path := Path, + certname := File} = Args, %% TODO: replace with an alias P = start_worker(self(), {generate_keys, Path, File}), State#{exec_state => #{worker => P, status => "generating keys..."}} @@ -475,9 +478,9 @@ ensure_exec_state(seed, State) -> %% Do wrapping of the status line State; #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + #{node := Node, + path := Path, + dirs := Dirs} = Args, %% TODO: replace with an alias P = start_worker(self(), {seed, Node, Path, Dirs}), DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), @@ -489,9 +492,9 @@ ensure_exec_state('remote-seed', State) -> %% Do wrapping of the status line State; #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), + #{node := Node, + peer := P, + dirs := Dirs} = Args, %% TODO: replace with an alias W = start_worker(self(), {'remote-seed', Node, P, Dirs}), DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), diff --git a/cli/revault_cli/src/revault_curses.erl b/cli/revault_cli/src/revault_curses.erl index c1dc232..1c8e967 100644 --- a/cli/revault_cli/src/revault_curses.erl +++ b/cli/revault_cli/src/revault_curses.erl @@ -25,13 +25,20 @@ label := string(), help := string(), type := val_type()}. +-type exec_arg() :: #{type_name() := term()}. %% TODO: split internal state from callback state --type state() :: map(). +-type nstate() :: #{mode := menu | action | exec, + hover_menu := menu_key(), + peer := undefined | atom(), + args := [arg(), ...], + state := state()}. +-type state() :: term(). -callback menu_order() -> [menu_key(), ...]. -callback menu_help(menu_key()) -> string(). -callback args() -> #{menu_key() := arg()}. --callback render_exec(menu_key(), max_lines(), max_cols(), state()) -> +-callback init() -> state(). +-callback render_exec(menu_key(), exec_arg(), max_lines(), max_cols(), state()) -> {render_mode(), state(), lines()}. -callback handle_exec(input, integer(), menu_key(), state()) -> {ok | done, state()}; (event, term(), menu_key(), state()) -> {ok | done, state()}. @@ -87,7 +94,8 @@ state(Mod, Old) -> menu => undefined, peer => undefined, dirs => undefined, - args => #{} + args => #{}, + state => Mod:init() }, Tmp0 = maps:merge(Default, Old), %% Refresh the layout to show proper coordinates @@ -197,13 +205,16 @@ show_exec(_Mod, State=#{mode := Mode, State#{exec_coords => {{Y,0},{Y,X}}}; show_exec(Mod, State=#{mode := exec, menu := Action, - action_coords := {_, {ActionY,MaxX}}}) -> + action_coords := {_, {ActionY,MaxX}}, + exec_args := Args, + state := ModState}) -> MinY = ActionY, %% expect line-based output in a list MaxLines = ?EXEC_LINES, MaxCols = MaxX-4, MaxY = MinY + MaxLines, - {RenderMode, ExecState, Lines} = Mod:render_exec(Action, MaxLines, MaxCols, State), + ModArgs = maps:from_list([{N, V} || #{name := N, val := V} <- Args]), + {RenderMode, NewModState, Lines} = Mod:render_exec(Action, ModArgs, MaxLines, MaxCols, ModState), case RenderMode of raw -> render_raw(Lines, {MinY,0}, {MaxY, MaxX}); @@ -217,7 +228,8 @@ show_exec(Mod, State=#{mode := exec, render_raw(clip(Lines, Offsets, MaxLines, MaxCols), {MinY,0}, {MaxY, MaxX}) end, - ExecState#{exec_coords => {{MinY,0},{MaxY,MaxX}}}. + State#{exec_coords => {{MinY,0},{MaxY,MaxX}}, + state => NewModState}. end_table(_Mod, State=#{exec_coords := {_, {Y,X}}}) -> str(Y+1, 0, ["╚", lists:duplicate(X-1, "═") ,"╝"]), @@ -467,6 +479,7 @@ convert_args(State, Args) -> %%%%%%%%%%%%%%%%%%%%% %%% MAIN TUI LOOP %%% %%%%%%%%%%%%%%%%%%%%% +-spec loop(module(), nstate()) -> no_return(). loop(Mod, OldState) -> State = #{mode := Mode} = state(Mod, OldState), case Mode of @@ -494,7 +507,7 @@ loop(Mod, OldState) -> loop(Mod, NewState); {done, TmpState} -> %% clear up the arg list and status messages - NewState = revault_curses:clear_status(maps:without([exec_state], TmpState#{mode => action})), + NewState = revault_curses:clear_status(TmpState#{mode => action}), cecho:erase(), loop(Mod, NewState) end @@ -697,9 +710,10 @@ handle_action({input, UnknownChar}, Action, TmpState) -> ), {ok, State}. -handle_exec(Mod, {Type, Msg}, Action, State) -> +handle_exec(Mod, {Type, Msg}, Action, State=#{state := ModState}) -> try - Mod:handle_exec(Type, Msg, Action, State) + {Key, NewModState} = Mod:handle_exec(Type, Msg, Action, ModState), + {Key, State#{state => NewModState}} catch error:function_clause:Stack -> case Stack of From 136a37f9d1da8cdd6ed586c79dfcf60da444b308 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 5 Jul 2025 17:11:22 -0400 Subject: [PATCH 25/27] no longer needing the TUI escript --- cli/revault_cli/src/revault_curses.escript | 1507 -------------------- 1 file changed, 1507 deletions(-) delete mode 100644 cli/revault_cli/src/revault_curses.escript diff --git a/cli/revault_cli/src/revault_curses.escript b/cli/revault_cli/src/revault_curses.escript deleted file mode 100644 index d70c531..0000000 --- a/cli/revault_cli/src/revault_curses.escript +++ /dev/null @@ -1,1507 +0,0 @@ -#!/usr/bin/env escript -%%! -noinput -name ncurses_cli -setcookie revault_cookie --mode(compile). --include_lib("cecho/include/cecho.hrl"). --export([main/1]). - -%% Name of the main running host, as specified in `config/vm.args' --define(DEFAULT_NODE, list_to_atom("revault@" ++ hd(tl(string:tokens(atom_to_list(node()), "@"))))). --define(KEY_BACKSPACE, 127). --define(KEY_CTRLA, 1). --define(KEY_CTRLE, 5). --define(KEY_CTRLD, 4). --define(KEY_ENTER, 10). --define(KEY_TEXT_RANGE(X), % ignore control codes - (not(X < 32) andalso - not(X >= 127 andalso X < 160))). - --define(EXEC_LINES, 15). --define(MAX_VALIDATION_DELAY, 150). % longest time to validate input, in ms --define(LOG(X), - ok). - %(fun() -> - % {ok, IoH} = file:open("/tmp/revaultlogcli", [append]), - % file:write(IoH, io_lib:format("~p~n", [X])), - % file:close(IoH) - %end)()). - - -%% 0 0 1 1 2 2 3 3 4 4 5 5 6 6 6 -%% 0 5 0 5 0 5 0 5 0 5 0 5 0 5 7 -%% ╔══════╤══════╤══════╤════════╤═══════════════╤══════╤═════════════╗ -%% 1 ║ list │ scan │ SYNC │ status │ generate-keys │ seed │ remote-seed ║ -%% ╟──────┴──────┴──────┴────────┴───────────────┴──────┴─────────────╢ -%% 3 ║ Local Node (ok): revault@node() ║ -%% 4 ║ Peer (X): …/peername ║ -%% 5 ║ Dirs: …/?/dir_a, dir_bigger, dir_c, dir_d ║ -%% ╟──────────────────────────────────────────────────────────────────╢ -%% 7 ║ SCAN SYNC ║ -%% ║ dir_a: ... ... ║ -%% ║ dir_bigger: ok ... ║ -%% 10 ║ dir_c: ok ok ║ -%% ║ dir_d: ok X ║ -%% ║ ║ -%% 13 ╚══════════════════════════════════════════════════════════════════╝ -%% 14 ╰─ some status -%% even multi-line... - -%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%% CUSTOMIZING OPTIONS %%% -%%%%%%%%%%%%%%%%%%%%%%%%%%% -menu_order() -> - [list, scan, sync, status, 'generate-keys', seed, 'remote-seed']. - -menu_help(list) -> "Show configuration and current settings"; -menu_help(scan) -> "Scan directories for changes"; -menu_help(sync) -> "Synchronize files with remote peer"; -menu_help(status) -> "Display current ReVault instance's configuration status"; -menu_help('generate-keys') -> "Generate TLS certificates for secure connections"; -menu_help(seed) -> "Create initial seed data to a directory, to use in a client"; -menu_help('remote-seed') -> "Create seed data as a client, from remote peer". - -args() -> - #{list => [ - #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, - help => "ReVault instance to connect to"} - ], - scan => [ - #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, - help => "Local ReVault instance to connect to"}, - #{name => dirs, label => "Dirs", - type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, - help => "List of directories to scan"} - ], - sync => [ - #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, - help => "Local ReVault instance to connect to"}, - #{name => dirs, label => "Dirs", - type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, - help => "List of directories to scan"}, - #{name => peer, label => "Peer Node", - type => {string, "^(?:\\s*)?(.+)(?:\\s*)?$", fun check_peer/2}, default => fun default_peers/1, - help => "Peer to sync against"} - ], - status => [ - #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, - help => "ReVault instance to connect to"} - ], - 'generate-keys' => [ - #{name => certname, label => "Certificate Name", - % the string regex 'trims' leading and trailing whitespace - type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "revault", - help => "Name of the key files generated"}, - #{name => path, label => "Certificate Directory", - type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "./", - help => "Directory where the key files will be placed"} - ], - seed => [ - #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, - help => "ReVault instance to connect to"}, - #{name => path, label => "Fork Seed Directory", - type => {string, "[^\\s]+.*[^\\s]+", fun check_ignore/2}, default => "./forked/", - help => "path of the base directory where the forked data will be located."}, - #{name => dirs, label => "Dirs", - type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, - help => "List of directories to fork"} - ], - 'remote-seed' => [ - #{name => node, label => "Local Node", - type => {node, "[\\w.-]+@[\\w.-]+", fun check_connect/2}, default => ?DEFAULT_NODE, - help => "ReVault instance to connect to"}, - #{name => peer, label => "Peer Node", - type => {string, "^(?:\\s*)?(.+)(?:\\s*)?$", fun check_peer/2}, default => fun default_peers/1, - help => "Peer from which to fork a seed"}, - #{name => dirs, label => "Dirs", - %% TODO: replace list by 'peer_dirs' - type => {list, fun parse_list/2, fun check_dirs/2}, default => fun default_dirs/1, - help => "List of directories to fork"} - ] - }. - -parse_list(String, State) -> - try - %% drop surrounding whitespace and split on commas - S = string:trim(String, both), - L = re:split(S, "[\\s]*,[\\s]*", [{return, binary}, unicode]), - %% ignore empty results (<<>>) in returned value - {ok, [B || B <- L], State} - catch - _:_ -> {error, invalid, State} - end. - -parse_regex(Re, String, State) -> - case re:run(String, Re, [{capture, first, binary}, unicode]) of - {match, [Str]} -> {ok, Str, State}; - nomatch -> {error, invalid, State} - end. - -parse_with_fun(node, F, Str, State) -> - maybe - {ok, NewStr, NewState} ?= F(Str, State), - Node = binary_to_atom(NewStr), - {ok, Node, NewState} - end; -parse_with_fun(_Type, F, Str, State) -> - F(Str, State). - -default_dirs(#{local_node := Node}) -> - try config(Node) of - {config, _Path, Config} -> - #{<<"dirs">> := DirMap} = Config, - maps:keys(DirMap) - catch - _E:_R -> [] - end. - -default_peers(State = #{local_node := Node}) -> - DirList = maps:get(dir_list, State, []), - try config(Node) of - {config, _Path, Config} -> - #{<<"peers">> := PeerMap} = Config, - Needed = ordsets:from_list(DirList), - Peers = [Peer - || Peer <- maps:keys(PeerMap), - Dirs <- [maps:get(<<"sync">>, maps:get(Peer, PeerMap))], - ordsets:is_subset(Needed, ordsets:from_list(Dirs))], - %% Flatten into a string, since peer data espects a string. - unicode:characters_to_binary(lists:join(", ", Peers)) - catch - _E:_R -> [] - end. - -check_connect(_State, Node) -> - case connect_nonblocking(Node) of - ok -> - case revault_node(Node) of - ok -> ok; - _ -> {error, non_revault_node} - end; - timeout -> - {error, connection_timeout}; - _ -> - {error, connection_failure} - end. - -check_dirs(#{local_node := Node}, Dirs) -> - try config(Node) of - {config, _Path, Config} -> - #{<<"dirs">> := DirMap} = Config, - ValidDirs = maps:keys(DirMap), - case Dirs -- ValidDirs of - [] -> ok; - Others -> {error, {unknown_dirs, Others}} - end - catch - E:R -> {error, {E,R}} - end. - -check_peer(State = #{local_node := Node}, Peer) -> - DirList = maps:get(dir_list, State, []), - try config(Node) of - {config, _Path, Config} -> - #{<<"peers">> := PeerMap} = Config, - Peers = [ValidPeer - || ValidPeer <- maps:keys(PeerMap)], - case lists:member(Peer, Peers) of - true -> - Needed = ordsets:from_list(DirList), - PeerDirs = maps:get(<<"sync">>, maps:get(Peer, PeerMap, #{}), []), - case ordsets:is_subset(Needed, ordsets:from_list(PeerDirs)) of - true -> ok; - false -> {error, {mismatching_dirs, Peer, Needed, PeerDirs}} - end; - false -> - {error, {unknown_peer, Peer, Peers}} - end - catch - E:R -> {error, {E,R}} - end. - -check_ignore(_, _) -> - ok. -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%% DEFINING THE WHOLE UI THINGY %%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -main(_) -> - setup(), - State = state(#{}), - cecho:refresh(), - Pid = self(), - spawn_link(fun F() -> - Pid ! {input, cecho:getch()}, - F() - end), - loop(select_menu(State, list)). - -setup() -> - logger:remove_handler(default), - application:ensure_all_started(cecho), - %% go in character-by-charcter mode - cecho:cbreak(), - %% don't show output - cecho:noecho(), - %% give keypad access - cecho:keypad(?ceSTDSCR, true), - %% don't wait on ESC keys - cecho:set_escdelay(25), - %% initial cursor position - cecho:move(1,1), - ok. - -state(Old) -> - Default = #{ - mode => menu, - hover_menu => hd(menu_order()), - node => ?DEFAULT_NODE, - connected => false, - menu => undefined, - peer => undefined, - dirs => undefined, - args => #{} - }, - Tmp0 = maps:merge(Default, Old), - %% Refresh the layout to show proper coordinates - Tmp1 = show_menu(Tmp0), - Tmp2 = show_action(Tmp1), - Tmp3 = show_exec(Tmp2), - Tmp4 = end_table(Tmp3), - cecho:refresh(), - Tmp4. - - -show_menu(State) -> - #{menu := Chosen, hover_menu := Hover, mode := Mode} = State, - StringMenu = [atom_to_list(X) || X <- menu_order()], - TopRow = ["╔═", - lists:join("═╤═", [lists:duplicate(string:length(X), "═") - || X <- StringMenu]), - "═╗"], - MenuRow = ["║ ", lists:join(" │ ", [format_menu(X, Chosen) || X <- StringMenu]), " ║"], - BottomRow = ["╟─", - lists:join("─┴─", [lists:duplicate(string:length(X), "─") - || X <- StringMenu]), - "─╢"], - {_, MenuMap, CoordMap} = lists:foldl( - fun(X, {N,M,C}) -> - XStr = atom_to_list(X), - {N+length(XStr)+3, - M#{X => {1,N}}, - C#{{1,N} => X}} - end, - {2, #{}, #{}}, - menu_order() - ), - Width = string:length(MenuRow)-1, - str(0, 0, TopRow), - str(1, 0, MenuRow), - str(2, 0, BottomRow), - NewState = State#{menu_coords => {{0,0}, {2,Width}}, - menu_map => MenuMap, - menu_coord_map => CoordMap, - menu_init_pos => {1,2}}, - %% set cursor if in menu mode and show help for hovered item - case {Mode, Hover} of - {menu, Hover} -> - MoveTo = menu_pos(NewState, Hover), - mv(MoveTo), - set_status(NewState, menu_help(Hover)); - _ -> - NewState - end. - -show_action(State = #{mode := Mode, - menu_coords := {_, End}}) when Mode == menu -> - State#{action_coords => {End, End}}; -show_action(State = #{mode := Mode, menu := Action, - menu_coords := {_, {MenuY, MaxX}}}) when Mode == action; - Mode == exec -> - MinY = MenuY, - %% TODO: clip lines that are too long - {ArgState, Args} = arg_output(State, Action, - maps:get(action_args, State, - maps:get(Action, args(), []))), - MaxY = lists:foldl(fun(Arg, Y) -> - #{line := {Label, Val}} = Arg, - Line = [Label, ": ", Val], - str(Y+1, 0, ["║ ", string:pad(Line, MaxX-3), " ║"]), - Y+1 - end, MinY, Args), - %% Ranges - {_, RevRanges} = lists:foldl(fun(Arg, {Y, Acc}) -> - #{line := {Label, _}} = Arg, - {Y+1, - [Arg#{range => {{Y+1, string:length(Label)+2+2}, {Y+1, MaxX-2}}} | Acc]} - end, {MinY, []}, Args), - Ranges = lists:reverse(RevRanges), - %% Set position - CurrentY = case cecho:getyx() of - {CurY, CurX} when CurY >= MinY, CurY =< MaxY, - CurX >= 2, CurX =< MaxX -> - CurY; - _ -> - %% Outside the box, move it to a known location - case Ranges of - [] -> - mv({MinY, 2}), - MinY; - [#{range := {First, _Last}}|_] -> - mv(First), - element(1, First) - end - end, - {ExtraLines, TmpState} = case Mode of - action -> - NthArg = CurrentY - MinY, - #{help := Help} = lists:nth(NthArg, Args), - {0, maybe_set_status(ArgState, Help)}; % this is the last section - _ -> - %% terminate table section - BottomRow = ["╟", lists:duplicate(MaxX-1, "─"), "╢"], - str(MaxY+1, 0, BottomRow), - {1, ArgState} - end, - TmpState#{action_coords => {{MinY,0}, {MaxY+ExtraLines,MaxX}}, - action_args => Ranges, - action_init_pos => {MinY, 2}}. - -show_exec(State=#{mode := Mode, - action_coords := {_, {Y,X}}}) when Mode =/= exec -> - State#{exec_coords => {{Y,0},{Y,X}}}; -show_exec(State=#{mode := exec, - menu := Action, - action_coords := {_, {ActionY,MaxX}}}) -> - MinY = ActionY, - %% expect line-based output in a list - MaxLines = ?EXEC_LINES, - MaxCols = MaxX-4, - MaxY = MinY + MaxLines, - {RenderMode, ExecState, Lines} = render_exec(Action, MaxLines, MaxCols, State), - case RenderMode of - raw -> - render_raw(Lines, {MinY,0}, {MaxY, MaxX}); - wrap -> - render_raw(wrap(Lines, MaxLines, MaxCols), - {MinY,0}, {MaxY, MaxX}); - clip -> - render_raw(clip(Lines, {0,0}, MaxLines, MaxCols), - {MinY,0}, {MaxY, MaxX}); - {clip, Offsets} -> - render_raw(clip(Lines, Offsets, MaxLines, MaxCols), - {MinY,0}, {MaxY, MaxX}) - end, - ExecState#{exec_coords => {{MinY,0},{MaxY,MaxX}}}. - -end_table(State=#{exec_coords := {_, {Y,X}}}) -> - str(Y+1, 0, ["╚", lists:duplicate(X-1, "═") ,"╝"]), - str(Y+2, 0, " ╰─ "), - %% Clear status area and render status if present - {StatusY, StatusX} = {Y+2, 5}, - %% Clear the entire status line - {MaxY,MaxX} = cecho:getmaxyx(), - [str(LY, StatusX, lists:duplicate(MaxX-StatusX, $\s)) || LY <- lists:seq(StatusY,MaxY)], - %% Render status message if present - case State of - #{status_message := StatusMsg} -> - str(StatusY, StatusX, StatusMsg); - _ -> - ok - end, - State#{status_coords => {{Y+1,0}, {StatusY,StatusX}}, - status_init_pos => {StatusY,StatusX}}. - -set_status(State, Str) -> - State#{status_message => Str}. - -maybe_set_status(State=#{status_message := _}, _) -> State; -maybe_set_status(State, Str) -> set_status(State, Str). - -clear_status(State) -> - maps:without([status_message], State). - -render_raw(Lines, {MinY, MinX}, {MaxY, MaxX}) -> - LinesY = lists:foldl(fun(Line, Y) -> - str(Y+1, MinX, ["║ ", string:pad(Line, MaxX-MinX-3), " ║"]), - Y+1 - end, MinY, Lines), - [str(LineY, MinX, ["║", lists:duplicate(MaxX-MinX-1, " "), "║"]) - || LineY <- lists:seq(LinesY+1, MaxY)]. - -loop(OldState) -> - State = #{mode := Mode} = state(OldState), - case Mode of - menu -> - receive - {input, Input} -> - {ok, NewState} = handle_menu({input, Input}, State), - loop(NewState) - end; - action -> - #{menu := Action} = State, - receive - {input, Input} -> - {ok, NewState} = handle_action({input, Input}, Action, clear_status(State)), - loop(NewState) - end; - exec -> - #{menu := Action} = State, - receive - {input, Input} -> - {ok, NewState} = handle_exec({input, Input}, Action, State), - loop(NewState); - {revault, Action, _} = Event -> - {ok, NewState} = handle_exec(Event, Action, State), - loop(NewState) - end - end. - -handle_menu({input, Key}, TmpState) -> - Pos = cecho:getyx(), - case Key of - ?ceKEY_RIGHT -> - NewMenu = next(menu_at(TmpState, Pos), menu_order()), - State = select_menu(TmpState, NewMenu), - {ok, State}; - ?ceKEY_LEFT -> - NewMenu = prev(menu_at(TmpState, Pos), menu_order()), - State = select_menu(TmpState, NewMenu), - mv_by({0,0}), - {ok, State}; - $\n -> - Menu = menu_at(TmpState, Pos), - State = clear_status(enter_menu(TmpState, Menu)), - {ok, State}; - UnknownChar -> - State = set_status( - TmpState, - io_lib:format("Unknown menu character: ~w", [UnknownChar]) - ), - {ok, State} - end. - -handle_action({input, ?ceKEY_ESC}, _Action, TmpState = #{menu := _Menu}) -> - %% exit the menu - TmpState2 = TmpState#{mode => menu, menu => undefined}, - %% clear up the arg list - %% TODO: cache by action? - State = maps:without([action_args], TmpState2), - cecho:erase(), - {ok, State}; -handle_action({input, ?ceKEY_DOWN}, _Action, State = #{action_args := Args}) -> - {Y,_} = cecho:getyx(), - After = lists:dropwhile(fun(#{range := {_, {MaxY,_}}}) -> Y >= MaxY end, Args), - case After of - [#{range := {Pos, _}}|_] -> mv(Pos); - _ -> ok - end, - {ok, State}; -handle_action({input, ?ceKEY_UP}, _Action, State = #{action_args := Args}) -> - {Y,_} = cecho:getyx(), - Before = lists:takewhile(fun(#{range := {{MinY,_}, _}}) -> Y > MinY end, Args), - case lists:reverse(Before) of - [#{range := {Pos, _}}|_] -> mv(Pos); - _ -> ok - end, - {ok, State}; -handle_action({input, ?ceKEY_LEFT}, _Action, State = #{action_args := Args}) -> - {Y,X} = cecho:getyx(), - {value, #{range := {{_,MinX},_}}} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - X > MinX andalso mv_by({0,-1}), - {ok, State}; -handle_action({input, ?ceKEY_RIGHT}, _Action, State = #{action_args := Args}) -> - {Y,X} = cecho:getyx(), - {value, #{range := {{_,MinX}, {_,MaxX}}, - line := {_, Str}}} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - X < MaxX andalso X < MinX+string:length(Str) andalso mv_by({0,1}), - {ok, State}; -handle_action({input, ?KEY_CTRLA}, _Action, State = #{action_args := Args}) -> - {Y,_} = cecho:getyx(), - {value, #{range := {{_,MinX},_}}} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - mv({Y, MinX}), - {ok, State}; -handle_action({input, ?KEY_CTRLE}, _Action, State = #{action_args := Args}) -> - {Y,_} = cecho:getyx(), - {value, #{range := {{_,MinX},_}, - line := {_, Str}}} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - mv({Y,MinX+string:length(Str)}), - {ok, State}; -handle_action({input, ?KEY_BACKSPACE}, _Action, State = #{action_args := Args}) -> - {Y,X} = cecho:getyx(), - {value, Arg} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - #{range := {{_,MinX},{_,MaxX}}, - line := {Label,Str}} = Arg, - NewStr = case X > MinX of - true -> % can go back - Pre = string:slice(Str, 0, (X-MinX)-1), - Post = string:slice(Str, X-MinX), - Edited = [Pre,Post], - str(Y, MinX, string:pad("", MaxX-MinX)), - str(Y, MinX, Edited), - mv_by({0,-1}), - Edited; - false -> - Str - end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, - unparsed => NewStr}), - {ok, State#{action_args=>NewArgs}}; -handle_action({input, ?ceKEY_DEL}, _Action, State = #{action_args := Args}) -> - {Y,X} = cecho:getyx(), - {value, Arg} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - #{range := {{_,MinX},{_,MaxX}}, - line := {Label,Str}} = Arg, - NewStr = case X >= MinX of - true -> % can go back - Pre = string:slice(Str, 0, X-MinX), - Post = string:slice(Str, (X-MinX)+1), - Edited = [Pre,Post], - str(Y, MinX, string:pad("", MaxX-MinX)), - str(Y, MinX, Edited), - Edited; - false -> - Str - end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, - unparsed => NewStr}), - {ok, State#{action_args=>NewArgs}}; -handle_action({input, ?KEY_CTRLD}, _Action, State = #{action_args := Args}) -> - {Y,X} = cecho:getyx(), - {value, Arg} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - #{range := {{_,MinX},{_,MaxX}}, - line := {Label,Str}} = Arg, - NewStr = case X >= MinX of - true -> % can go back - Edited = string:slice(Str, 0, X-MinX), - str(Y, MinX, string:pad("", MaxX-MinX)), - str(Y, MinX, Edited), - Edited; - false -> - Str - end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, - unparsed => NewStr}), - {ok, State#{action_args=>NewArgs}}; -handle_action({input, Char}, _Action, State = #{action_args := Args}) when ?KEY_TEXT_RANGE(Char) -> - %% text input! - {Y,X} = cecho:getyx(), - {value, Arg} = lists:search( - fun(#{range := {{MinY,_}, {MaxY,_}}}) -> Y >= MinY andalso Y =< MaxY end, - Args - ), - #{range := {{_,MinX},{_,MaxX}}, - line := {Label,Str}} = Arg, - NewStr = case X < MaxX andalso X >= MinX - andalso X =< MinX+string:length(Str) of - true -> - Pre = string:slice(Str, 0, X-MinX), - Post = string:slice(Str, (X-MinX)), - Edited = string:slice([Pre, Char, Post], 0, MaxX-MinX), - str(Y, MinX, string:pad("", MaxX-MinX)), - str(Y, MinX, Edited), - mv_by({0,1}), - Edited; - false -> - Str - end, - NewArgs = replace(Args, Arg, Arg#{line => {Label,NewStr}, - unparsed => NewStr}), - {ok, State#{action_args=>NewArgs}}; -handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> - %% revalidate all values in all ranges; if any error - %% is found, show it in the status line. - %% if none are found, extract as clean options, and - %% switch to execution mode. - case validate_args(TmpState, Action, Args) of - ok -> - State = set_status(TmpState, "ok."), - {ok, State#{mode => exec, exec_args => Args}}; - {error, [{#{line := {Label, _}}, Reason}|_]} -> - State = set_status( - TmpState, - io_lib:format("Validation issue in ~ts: ~p", [Label, Reason]) - ), - {ok, State} - end; -handle_action({input, UnknownChar}, Action, TmpState) -> - State = set_status( - TmpState, - io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) - ), - {ok, State}. - -handle_exec({input, ?ceKEY_ESC}, _Action, TmpState) -> - %% TODO: clean up workers if any - %% clear up the arg list and status messages - State = clear_status(maps:without([exec_state], TmpState#{mode => action})), - cecho:erase(), - {ok, State}; -%% List exec -handle_exec({input, ?ceKEY_DOWN}, list, State = #{exec_state:=ES}) -> - {Y,X} = maps:get(offset, ES, {0, 0}), - {ok, State#{exec_state => ES#{offset => {Y+1, X}}}}; -handle_exec({input, ?ceKEY_UP}, list, State = #{exec_state:=ES}) -> - {Y,X} = maps:get(offset, ES, {0, 0}), - {ok, State#{exec_state => ES#{offset => {max(0,Y-1), X}}}}; -handle_exec({input, ?ceKEY_RIGHT}, list, State = #{exec_state:=ES}) -> - {Y,X} = maps:get(offset, ES, {0, 0}), - {ok, State#{exec_state => ES#{offset => {Y, X+1}}}}; -handle_exec({input, ?ceKEY_LEFT}, list, State = #{exec_state:=ES}) -> - {Y,X} = maps:get(offset, ES, {0, 0}), - {ok, State#{exec_state => ES#{offset => {Y, max(0,X-1)}}}}; -handle_exec({input, ?ceKEY_PGDOWN}, list, State = #{exec_state:=ES}) -> - {Y,X} = maps:get(offset, ES, {0, 0}), - Shift = ?EXEC_LINES-1, - {ok, State#{exec_state => ES#{offset => {Y+Shift, X}}}}; -handle_exec({input, ?ceKEY_PGUP}, list, State = #{exec_state:=ES}) -> - {Y,X} = maps:get(offset, ES, {0, 0}), - Shift = ?EXEC_LINES-1, - {ok, State#{exec_state => ES#{offset => {max(0,Y-Shift), X}}}}; -%% TODO: ctrlA, ctrlE -%% Scan exec -handle_exec({revault, scan, done}, scan, State=#{exec_state:=ES}) -> - %% unset the workers - case maps:get(worker, ES, undefined) of - undefined -> - ok; - Pid -> - %% make sure the worker is torn down fully, even - %% if this is blocking - Pid ! done, - Ref = erlang:monitor(process, Pid), - receive - {'DOWN', Ref, process, _, _} -> - ok - after 5000 -> - %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY - %% so consider this a hard failure. - error(bad_worker_shutdown) - end - end, - {ok, State}; -handle_exec({revault, scan, {Dir, Status}}, scan, State=#{exec_state:=ES}) -> - #{dirs := Statuses} = ES, - {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -handle_exec({input, ?KEY_ENTER}, scan, State) -> - %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {revault, scan, done}, - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, - {ok, State}; -%% Sync exec -handle_exec({revault, sync, done}, sync, State=#{exec_state:=ES}) -> - %% unset the workers - case maps:get(worker, ES, undefined) of - undefined -> - ok; - Pid -> - %% make sure the worker is torn down fully, even - %% if this is blocking - Pid ! done, - Ref = erlang:monitor(process, Pid), - receive - {'DOWN', Ref, process, _, _} -> - ok - after 5000 -> - %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY - %% so consider this a hard failure. - error(bad_worker_shutdown) - end - end, - {ok, State}; -handle_exec({revault, sync, {Dir, Status}}, sync, State=#{exec_state:=ES}) -> - #{dirs := Statuses} = ES, - {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -handle_exec({input, ?KEY_ENTER}, sync, State) -> - %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {revault, scan, done}, - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, - {ok, State}; -%% Status -handle_exec({revault, status, done}, status, State) -> - {ok, State}; -handle_exec({revault, status, {ok, Val}}, status, State=#{exec_state:=ES}) -> - {ok, State#{exec_state => ES#{status => Val}}}; -handle_exec({input, ?KEY_ENTER}, status, State) -> - %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {revault, status, done}, - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, - {ok, State}; -%% Generate-Keys -handle_exec({revault, 'generate-keys', {ok, Val}}, 'generate-keys', State=#{exec_state:=ES}) -> - {ok, State#{exec_state => ES#{status => Val}}}; -handle_exec({input, ?KEY_ENTER}, 'generate-keys', State) -> - %% Do a refresh by exiting the menu and re-entering again. Quite hacky. - self() ! {input, ?ceKEY_ESC}, - self() ! {input, ?KEY_ENTER}, - {ok, State}; -%% Seed exec -handle_exec({revault, seed, done}, seed, State=#{exec_state:=ES}) -> - %% unset the workers - case maps:get(worker, ES, undefined) of - undefined -> - ok; - Pid -> - %% make sure the worker is torn down fully, even - %% if this is blocking - Pid ! done, - Ref = erlang:monitor(process, Pid), - receive - {'DOWN', Ref, process, _, _} -> - ok - after 5000 -> - %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY - %% so consider this a hard failure. - error(bad_worker_shutdown) - end - end, - {ok, State}; -handle_exec({revault, seed, {Dir, Status}}, seed, State=#{exec_state:=ES}) -> - #{dirs := Statuses} = ES, - {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -% do not endlessly re-seed? -% handle_exec({input, ?KEY_ENTER}, seed, State) -> -% %% Do a refresh by exiting the menu and re-entering again. Quite hacky. -% self() ! {revault, seed, done}, -% self() ! {input, ?ceKEY_ESC}, -% self() ! {input, ?KEY_ENTER}, -% {ok, State}; -%% remote-seed exec -handle_exec({revault, 'remote-seed', done}, 'remote-seed', State=#{exec_state:=ES}) -> - %% unset the workers - case maps:get(worker, ES, undefined) of - undefined -> - ok; - Pid -> - %% make sure the worker is torn down fully, even - %% if this is blocking - Pid ! done, - Ref = erlang:monitor(process, Pid), - receive - {'DOWN', Ref, process, _, _} -> - ok - after 5000 -> - %% we ideally wouldn't wait more than ?MAX_VALIDATION_DELAY - %% so consider this a hard failure. - error(bad_worker_shutdown) - end - end, - {ok, State}; -handle_exec({revault, 'remote-seed', {Dir, Status}}, 'remote-seed', State=#{exec_state:=ES}) -> - #{dirs := Statuses} = ES, - file:write_file("/tmp/dbg", io_lib:format("~p~n", [{Dir, Status}])), - {ok, State#{exec_state => ES#{dirs => Statuses#{Dir => Status}}}}; -%% Generic exec -handle_exec({input, UnknownChar}, Action, TmpState) -> - State = set_status( - TmpState, - io_lib:format("Unknown character in ~p: ~w", [Action, UnknownChar]) - ), - {ok, State}; -handle_exec({revault, EventAct, Event}, Act, TmpState) -> - State = set_status( - TmpState, - io_lib:format("Got unexpected ~p event in ~p: ~p", [EventAct, Act, Event]) - ), - {ok, State}; -handle_exec(Msg, Action, TmpState) -> - State = set_status( - TmpState, - io_lib:format("Got unexpected message in ~p: ~p", [Action, Msg]) - ), - {ok, State}. - -mv_by({OffsetY, OffsetX}) -> - {CY, CX} = cecho:getyx(), - cecho:move(CY+OffsetY, CX+OffsetX). - -mv({Y,X}) -> - cecho:move(Y, X). - -str(Y, X, Str) -> - {OrigY, OrigX} = cecho:getyx(), - %% cecho expects a lists of bytes, so we gotta do some fun converting - cecho:mvaddstr(Y, X, binary_to_list(unicode:characters_to_binary(Str))), - cecho:move(OrigY, OrigX). - -prev(K, L) -> next(K, lists:reverse(L)). - -next(_, [N]) -> N; -next(K, [K,N|_]) -> N; -next(K, [_|T]) -> next(K, T). - -menu_at(#{menu_coord_map := CoordMap}, Coord) -> - #{Coord := Menu} = CoordMap, - Menu. - -menu_pos(#{menu_map := M}, Menu) -> - #{Menu := Coord} = M, - Coord. - -enter_menu(State, Menu) -> - State#{mode => action, - menu => Menu}. - -select_menu(State, Menu) -> - mv(menu_pos(State, Menu)), - State#{hover_menu => Menu}. - -format_menu(X, Chosen) -> - case atom_to_list(Chosen) of - X -> string:uppercase(X); - _ -> X - end. - -arg_output(State, Action, Args) -> - arg_output(State, Action, Args, []). - -arg_output(State, _, [], Acc) -> - {State, lists:reverse(Acc)}; -arg_output(State, Action, [Arg|Args], Acc) when not is_map_key(val, Arg) -> - {NewState, NewArg} = arg_init(State, Action, Arg), - arg_output(NewState, Action, [NewArg|Args], Acc); -arg_output(State, Action, [Arg=#{unparsed := Unparsed}|Args], Acc) -> - %% refresh data of pre-parsed elements. - %% with the new value in place, apply the transformation to its internal - %% format for further commands - Ret = parse_arg(State, Action, Arg, Unparsed), - %% Store it all! - ?LOG({?LINE, parsed, maps:get(name, Arg), element(2, Ret)}), - case Ret of - {ok, Val, NewState} -> - arg_output(NewState, Action, - [maps:without([line, unparsed], Arg#{val => Val}) | Args], Acc); - {error, _Reason, NewState} -> - %% TODO: update status? - arg_output(NewState, Action, [maps:without([unparsed], Arg)|Args], Acc) - end; -arg_output(State, Action, [Arg=#{line := _} | Args], Acc) -> - arg_output(State, Action, Args, [Arg|Acc]); -arg_output(State, Action, [#{type := {node, _, _}, label := Label, val := NodeVal}=Arg|Args], Acc) -> - Status = case connect_nonblocking(NodeVal) of - ok -> - case revault_node(NodeVal) of - ok -> "ok"; - _ -> "?!" - end; - timeout -> - "??"; - _ -> - "!!" - end, - Line = {[Label, " (", Status, ")"], atom_to_list(NodeVal)}, - arg_output(State#{local_node => NodeVal}, Action, - [Arg#{line => Line}|Args], Acc); -arg_output(State, Action, [#{name := dirs, type := {list, _, _}, label := Label, val := DirList}=Arg|Args], Acc) -> - Line = {Label, lists:join(", ", DirList)}, - arg_output(State#{dir_list => DirList}, Action, - [Arg#{line => Line}|Args], Acc); -arg_output(State, Action, [#{type := {list, _, _}, label := Label, val := List}=Arg|Args], Acc) -> - Line = {Label, lists:join(", ", List)}, - arg_output(State, Action, [Arg#{line => Line}|Args], Acc); -arg_output(State, Action, [#{type := {string, _, _}, label := Label, val := Val}=Arg|Args], Acc) -> - Line = {Label, Val}, - arg_output(State, Action, [Arg#{line => Line}|Args], Acc); -arg_output(State, Action, [#{type := Unsupported}=Arg|Args], Acc) -> - #{label := Label} = Arg, - Line = {io_lib:format("[Unsupported] ~ts", [Label]), - io_lib:format("~p", [Unsupported])}, - arg_output(State, Action, [Arg#{line => Line}|Args], Acc). - -arg_init(State, _Action, Arg = #{type := {node, _, _}, default := Default}) -> - Val = maps:get(local_node, State, Default), - {State#{local_node => Val}, Arg#{val => Val}}; -arg_init(State, _Action, Arg = #{name := dirs, type := {list, _, _}, default := F}) -> - Default = F(State), - {State#{dir_list => Default}, Arg#{val => Default}}; -arg_init(State, _Action, Arg = #{type := {list, _, _}, default := F}) -> - {State, Arg#{val => F(State)}}; -arg_init(State, _Action, Arg = #{type := {string, _, _}, default := X}) -> - Default = if is_function(X, 1) -> X(State); - is_function(X) -> error(bad_arity); - true -> X - end, - {State, Arg#{val => Default}}; -arg_init(State, _Action, Arg = #{type := Unsupported}) -> - {State, Arg#{val => {error, Unsupported}}}. - -parse_arg(State, _Action, #{type := TypeInfo}, Unparsed) -> - case TypeInfo of - {T, F, _Validation} when is_function(F) -> - parse_with_fun(T, F, Unparsed, State); - {T, Regex, _Validation} when is_list(Regex); is_binary(Regex) -> - F = fun(String, St) -> parse_regex(Regex, String, St) end, - parse_with_fun(T, F, Unparsed, State) - end. - -render_exec(list, _MaxLines, _MaxCols, State) -> - NewState = ensure_exec_state(list, State), - #{exec_state := #{path := Path, config := Config, offset := {OffY,OffX}}} = NewState, - Brk = io_lib:format("~n", []), - Str = io_lib:format("Config parsed from ~ts:~n~p~n", [Path, Config]), - %% Fit lines and the whole thing in a "box" - Lines = string:lexemes(Str, Brk), - {{clip, {OffY,OffX}}, NewState, Lines}; -render_exec(scan, _MaxLines, _MaxCols, State) -> - NewState = ensure_exec_state(scan, State), - #{exec_state := #{dirs := Statuses}} = NewState, - LStatuses = lists:sort(maps:to_list(Statuses)), - %% TODO: support scrolling if you have more Dirs than MaxLines or - %% dirs that are too long by tracking clipping offsets. - LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", - case Status of - pending -> "??"; - ok -> "ok"; - _ -> "!!" - end] || {Dir, Status} <- LStatuses], - {clip, NewState, Strs}; -render_exec(sync, _MaxLines, _MaxCols, State) -> - NewState = ensure_exec_state(sync, State), - #{exec_state := #{dirs := Statuses}} = NewState, - LStatuses = lists:sort(maps:to_list(Statuses)), - %% TODO: support scrolling if you have more Dirs than MaxLines or - %% dirs that are too long by tracking clipping offsets - LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - Header = [string:pad("DIR", LongestDir+1, trailing, " "), " SCAN SYNC"], - Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", - case Status of - pending -> " ??"; - scanned -> " ok ??"; - synced -> " ok ok"; - _ -> " !! !!" - end] || {Dir, Status} <- LStatuses], - {clip, NewState, [Header | Strs]}; -render_exec(status, _MaxLines, _MaxCols, State) -> - NewState = ensure_exec_state(status, State), - #{exec_state := #{status := Status}} = NewState, - Strs = [io_lib:format("~p",[Status])], - {wrap, NewState, Strs}; -render_exec('generate-keys', _MaxLines, _MaxCols, State) -> - NewState = ensure_exec_state('generate-keys', State), - #{exec_state := #{status := Exists}} = NewState, - {wrap, NewState, Exists}; -render_exec(seed, _MaxLines, _MaxCols, State) -> - NewState = ensure_exec_state(seed, State), - #{exec_state := #{dirs := Statuses}} = NewState, - LStatuses = lists:sort(maps:to_list(Statuses)), - LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", - case Status of - pending -> "??"; - ok -> "ok"; - _ -> "!!" - end] || {Dir, Status} <- LStatuses], - {clip, NewState, Strs}; -render_exec('remote-seed', _MaxLines, _MaxCols, State) -> - NewState = ensure_exec_state('remote-seed', State), - #{exec_state := #{dirs := Statuses}} = NewState, - LStatuses = lists:sort(maps:to_list(Statuses)), - LongestDir = lists:max([string:length(D) || {D, _} <- LStatuses]), - Strs = [[string:pad([Dir, ":"], LongestDir+1, trailing, " "), " ", - case Status of - pending -> "??"; - {ok, _ITC} -> "ok"; - _ -> "!!" - end] || {Dir, Status} <- LStatuses], - {clip, NewState, Strs}; -render_exec(Action, _MaxLines, _MaxCols, State) -> - {clip, State, [[io_lib:format("Action ~p not implemented yet.", [Action])]]}. - -%% Helper function to ensure exec state is properly initialized -ensure_exec_state(list, State) -> - case State of - #{exec_state := #{path := _, config := _, offset := _}} -> - State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {ok, P, C} = erpc:call(Node, maestro_loader, current, []), - State#{exec_state => #{path => P, config => C, offset => {0,0}}} - end; -ensure_exec_state(scan, State) -> - case State of - #{exec_state := #{worker := _, dirs := _}} -> - State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {scan, Node, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - State#{exec_state => #{worker => P, dirs => DirStatuses}} - end; -ensure_exec_state(sync, State) -> - case State of - #{exec_state := #{worker := _, peer := _, dirs := _}} -> - State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - W = start_worker(self(), {sync, Node, P, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - State#{exec_state => #{worker => W, peer => P, dirs => DirStatuses}} - end; -ensure_exec_state(status, State) -> - case State of - #{exec_state := #{worker := _, status := _}} -> - State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {status, Node}), - State#{exec_state => #{worker => P, status => undefined}} - end; -ensure_exec_state('generate-keys', State) -> - case State of - #{exec_state := #{worker := _, status := _}} -> - %% Do wrapping of the status line - State; - #{exec_args := Args} -> - {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), - {value, #{val := File}} = lists:search(fun(#{name := N}) -> N == certname end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {generate_keys, Path, File}), - State#{exec_state => #{worker => P, status => "generating keys..."}} - end; -ensure_exec_state(seed, State) -> - case State of - #{exec_state := #{worker := _, dirs := _}} -> - %% Do wrapping of the status line - State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := Path}} = lists:search(fun(#{name := N}) -> N == path end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - P = start_worker(self(), {seed, Node, Path, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - State#{exec_state => #{worker => P, dirs => DirStatuses}} - end; -ensure_exec_state('remote-seed', State) -> - case State of - #{exec_state := #{worker := _, peer := _, dirs := _}} -> - %% Do wrapping of the status line - State; - #{exec_args := Args} -> - {value, #{val := Node}} = lists:search(fun(#{name := N}) -> N == node end, Args), - {value, #{val := P}} = lists:search(fun(#{name := N}) -> N == peer end, Args), - {value, #{val := Dirs}} = lists:search(fun(#{name := N}) -> N == dirs end, Args), - %% TODO: replace with an alias - W = start_worker(self(), {'remote-seed', Node, P, Dirs}), - DirStatuses = maps:from_list([{Dir, pending} || Dir <- Dirs]), - State#{exec_state => #{worker => W, peer => P, dirs => DirStatuses}} - end. - -replace([H|T], H, R) -> [R|T]; -replace([H|T], S, R) -> [H|replace(T, S, R)]. - -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -%%% IMPLEMENTATION HELPERS %%% -%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% - -connect(Node) -> - case net_kernel:connect_node(Node) of - ignored -> {error, no_dist}; - false -> {error, connection_failed}; - true -> ok - end. - -connect_nonblocking(Node) -> - timeout_call(?MAX_VALIDATION_DELAY, fun() -> connect(Node) end). - -%% small helper that defers a blocking call that can be long -%% to another process, such that the validation step can have a -%% ceiling for how long it takes before returning a value. -%% If the process times out, it is killed brutally. -timeout_call(Timeout, Fun) -> - P = self(), - R = make_ref(), - {Pid, Ref} = spawn_monitor(fun() -> - Res = Fun(), - P ! {R, Res} - end), - receive - {R, Res} -> - erlang:demonitor(R, [flush]), - Res; - {'DOWN', Ref, _, _, _} -> - {error, connection_attempt_failed} - after Timeout -> - erlang:exit(Pid, kill), - receive - {'DOWN', Ref, _, _, _} -> - timeout; - {R, Res} -> - erlang:demonitor(R, [flush]), - Res - end - end. - - --spec revault_node(atom()) -> ok | {error, term()}. -revault_node(Node) -> - try erpc:call(Node, maestro_loader, status, []) of - current -> ok; - outdated -> ok; - last_valid -> ok; - _ -> {error, unknown_status} - catch - E:R -> {error, {rpc, {E,R}}} - end. - -config(Node) -> - {ok, Path, Config} = erpc:call(Node, maestro_loader, current, []), - {config, Path, Config}. - -start_worker(ReplyTo, Call) -> - Parent = self(), - spawn_link(fun() -> worker(Parent, ReplyTo, Call) end). - -worker(Parent, ReplyTo, {scan, Node, Dirs}) -> - worker_scan(Parent, ReplyTo, Node, Dirs); -worker(Parent, ReplyTo, {sync, Node, Peer, Dirs}) -> - worker_sync(Parent, ReplyTo, Node, Peer, Dirs); -worker(Parent, ReplyTo, {status, Node}) -> - worker_status(Parent, ReplyTo, Node); -worker(Parent, ReplyTo, {generate_keys, Path, File}) -> - worker_generate_keys(Parent, ReplyTo, Path, File); -worker(Parent, ReplyTo, {seed, Node, Path, Dirs}) -> - worker_seed(Parent, ReplyTo, Node, Path, Dirs); -worker(Parent, ReplyTo, {'remote-seed', Node, Peer, Dirs}) -> - worker_remote_seed(Parent, ReplyTo, Node, Peer, Dirs). - -worker_scan(Parent, ReplyTo, Node, Dirs) -> - %% assume we are connected from arg validation time. - %% We have multiple directories, so scan them in parallel. - %% This requires setting up sub-workers, which incidentally lets us - %% also listen for interrupts from the parent. - process_flag(trap_exit, true), - ReqIds = lists:foldl(fun(Dir, Ids) -> - erpc:send_request(Node, - revault_dirmon_event, force_scan, [Dir, infinity], - Dir, Ids) - end, erpc:reqids_new(), Dirs), - worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds). - -worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> - receive - {'EXIT', Parent, Reason} -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - exit(Reason); - stop -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - unlink(Parent), - exit(shutdown) - after 0 -> - case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of - no_request -> - ReplyTo ! {revault, scan, done}, - exit(normal); - no_response -> - worker_scan_loop(Parent, ReplyTo, Node, Dirs, ReqIds); - {{response, Res}, Dir, NewIds} -> - ReplyTo ! {revault, scan, {Dir, Res}}, - worker_scan_loop(Parent, ReplyTo, Node, Dirs, NewIds) - end - end. - -worker_sync(Parent, ReplyTo, Node, Peer, Dirs) -> - %% assume we are connected from arg validation time. - %% We have multiple directories, so sync them in parallel. - %% This requires setting up sub-workers, which incidentally lets us - %% also listen for interrupts from the parent. - process_flag(trap_exit, true), - ReqIds = lists:foldl(fun(Dir, Ids) -> - erpc:send_request(Node, - revault_dirmon_event, force_scan, [Dir, infinity], - {scan, Dir}, Ids) - end, erpc:reqids_new(), Dirs), - worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds). - -worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> - receive - {'EXIT', Parent, Reason} -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - exit(Reason); - stop -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - unlink(Parent), - exit(shutdown) - after 0 -> - case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of - no_request -> - ReplyTo ! {revault, sync, done}, - exit(normal); - no_response -> - worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); - {{response, Res}, {scan, Dir}, TmpIds} -> - Status = case Res of - ok -> scanned; - Other -> Other - end, - ReplyTo ! {revault, sync, {Dir, Status}}, - NewIds = erpc:send_request( - Node, - revault_fsm, sync, [Dir, Peer], - {sync, Dir}, - TmpIds - ), - worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds); - {{response, Res}, {sync, Dir}, NewIds} -> - Status = case Res of - ok -> synced; - Other -> Other - end, - ReplyTo ! {revault, sync, {Dir, Status}}, - worker_sync_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) - end - end. - -worker_status(Parent, ReplyTo, Node) -> - process_flag(trap_exit, true), - ReqIds = erpc:send_request(Node, - maestro_loader, status, [], - status, erpc:reqids_new()), - worker_status_loop(Parent, ReplyTo,ReqIds). - -worker_status_loop(Parent, ReplyTo, ReqIds) -> - receive - {'EXIT', Parent, Reason} -> - exit(Reason); - stop -> - unlink(Parent), - exit(shutdown) - after 0 -> - case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of - no_request -> - ReplyTo ! {revault, status, done}, - exit(normal); - no_response -> - worker_status_loop(Parent, ReplyTo, ReqIds); - {{response, Res}, status, NewIds} -> - ReplyTo ! {revault, status, {ok, Res}}, - worker_status_loop(Parent, ReplyTo, NewIds) - end - end. - - -worker_generate_keys(Parent, ReplyTo, Path, File) -> - Res = make_selfsigned_cert(unicode:characters_to_list(Path), - unicode:characters_to_list(File)), - %% we actually don't have a loop, everything is local - %% and has already be run, so we just wait for a shutdown signal. - ReplyTo ! {revault, 'generate-keys', {ok, Res}}, - receive - {'EXIT', Parent, Reason} -> - exit(Reason); - stop -> - unlink(parent), - exit(shutdown) - end. - -worker_seed(Parent, ReplyTo, Node, Path, Dirs) -> - %% assume we are connected from arg validation time. - %% We have multiple directories, so scan them in parallel. - %% This requires setting up sub-workers, which incidentally lets us - %% also listen for interrupts from the parent. - process_flag(trap_exit, true), - ReqIds = lists:foldl(fun(Dir, Ids) -> - erpc:send_request(Node, - revault_fsm, seed_fork, [Dir, Path], - Dir, Ids) - end, erpc:reqids_new(), Dirs), - worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds). - -worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds) -> - receive - {'EXIT', Parent, Reason} -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - exit(Reason); - stop -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - unlink(Parent), - exit(shutdown) - after 0 -> - case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of - no_request -> - ReplyTo ! {revault, seed, done}, - exit(normal); - no_response -> - worker_seed_loop(Parent, ReplyTo, Node, Dirs, ReqIds); - {{response, Res}, Dir, NewIds} -> - ReplyTo ! {revault, seed, {Dir, Res}}, - worker_seed_loop(Parent, ReplyTo, Node, Dirs, NewIds) - end - end. - -worker_remote_seed(Parent, ReplyTo, Node, Peer, Dirs) -> - %% assume we are connected from arg validation time. - %% We have multiple directories, so scan them in parallel. - %% This requires setting up sub-workers, which incidentally lets us - %% also listen for interrupts from the parent. - process_flag(trap_exit, true), - ReqIds = lists:foldl(fun(Dir, Ids) -> - erpc:send_request(Node, - revault_fsm, id, [Dir, Peer], - Dir, Ids) - end, erpc:reqids_new(), Dirs), - worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds). - -worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds) -> - receive - {'EXIT', Parent, Reason} -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - exit(Reason); - stop -> - %% clean up all the workers by being linked to them and dying - %% an unclean death. - unlink(Parent), - exit(shutdown) - after 0 -> - case erpc:wait_response(ReqIds, ?MAX_VALIDATION_DELAY, true) of - no_request -> - ReplyTo ! {revault, 'remote-seed', done}, - exit(normal); - no_response -> - worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, ReqIds); - {{response, Res}, Dir, NewIds} -> - ReplyTo ! {revault, 'remote-seed', {Dir, Res}}, - worker_remote_seed_loop(Parent, ReplyTo, Node, Peer, Dirs, NewIds) - end - end. - -%% Copied from revault_tls -make_selfsigned_cert(Dir, CertName) -> - check_openssl_vsn(), - - Key = filename:join(Dir, CertName ++ ".key"), - Cert = filename:join(Dir, CertName ++ ".crt"), - ok = filelib:ensure_dir(Cert), - Cmd = io_lib:format( - "openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes " - "-keyout '~ts' -out '~ts' -subj '/CN=example.org' " - "-addext 'subjectAltName=DNS:example.org,DNS:www.example.org,IP:127.0.0.1'", - [Key, Cert] % TODO: escape quotes - ), - os:cmd(Cmd). - -check_openssl_vsn() -> - Vsn = os:cmd("openssl version"), - VsnMatch = "(Open|Libre)SSL ([0-9]+)\\.([0-9]+)\\.([0-9]+)", - case re:run(Vsn, VsnMatch, [{capture, all_but_first, list}]) of - {match, [Type, Major, Minor, Patch]} -> - try - check_openssl_vsn(Type, list_to_integer(Major), - list_to_integer(Minor), - list_to_integer(Patch)) - catch - error:bad_vsn -> - error({openssl_vsn, Vsn}) - end; - _ -> - error({openssl_vsn, Vsn}) - end. - -%% Using OpenSSL >= 1.1.1 or LibreSSL >= 3.1.0 -check_openssl_vsn("Libre", A, B, _) when A > 3; - A == 3, B >= 1 -> - ok; -check_openssl_vsn("Open", A, B, C) when A > 1; - A == 1, B > 1; - A == 1, B == 1, C >= 1 -> - ok; -check_openssl_vsn(_, _, _, _) -> - error(bad_vsn). - -wrap(Str, Lines, Width) -> - wrap(Str, 0, Width, 0, Lines, [[]]). - -wrap(_Str, Width, Width, Lines, Lines, Acc) -> - lists:reverse(Acc); -wrap(Str, Width, Width, Ln, Lines, [L|Acc]) -> - wrap(Str, 0, Width, Ln+1, Lines, [[],lists:reverse(L)|Acc]); -wrap(Str, W, Width, Ln, Lines, [L|Acc]) -> - case string:next_grapheme(Str) of - [Brk|Rest] when Brk == $\n; Brk == "\r\n" -> - wrap(Rest, 0, Width, Ln+1, Lines, [[], lists:reverse(L)|Acc]); - [C|Rest] -> - wrap(Rest, W+1, Width, Ln, Lines, [[C|L]|Acc]); - [] -> - lists:reverse([lists:reverse(L)|Acc]) - end. - -clip(Lines, {OffY, OffX}, MaxLines, MaxCols) -> - [string:slice(S, OffX, MaxCols) - || S <- lists:sublist(Lines, OffY+1, MaxLines)]. - -validate_args(State, Action, Args) -> - %% Validate all the arguments - {Errors, Status} = lists:foldl( - fun(Arg = #{line := {_Label, Str}}, {Acc, S}) -> - case parse_arg(State, Action, Arg, Str) of - {ok, _, _} -> {Acc, S}; - {error, Reason, _} -> {[{Arg, Reason}|Acc], error} - end - end, - {[], ok}, - Args - ), - case Status of - error -> - {error, Errors}; - ok -> - {_Valid, Invalid} = convert_args(State, Args), - case Invalid of - [] -> - ok; - Invalid -> - {error, Invalid} - end - end. - -convert_args(State, Args) -> - lists:foldl( - fun(Arg = #{val := Val, type := {_,_,F}}, {V,I}) -> - case F(State, Val) of - ok -> {[Arg|V], I}; - {error, Reason} -> {V, [{Arg, Reason}]} - end - end, - {[],[]}, - Args - ). From b0064ddb743c9de5a92ab44bc35d7b9148ea8916 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 5 Jul 2025 17:15:05 -0400 Subject: [PATCH 26/27] clean up workers on menu exit --- cli/revault_cli/src/revault_cli_mod.erl | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cli/revault_cli/src/revault_cli_mod.erl b/cli/revault_cli/src/revault_cli_mod.erl index 60eaa7e..07ec3ff 100644 --- a/cli/revault_cli/src/revault_cli_mod.erl +++ b/cli/revault_cli/src/revault_cli_mod.erl @@ -168,7 +168,10 @@ render_exec(Action, _MaxLines, _MaxCols, State) -> handle_exec(input, ?ceKEY_ESC, _Action, State) -> - %% TODO: clean up workers if any + case State of + #{exec_state := #{worker := P}} -> P ! stop; + _ -> ok + end, {done, maps:without([exec_state], State)}; %% List exec handle_exec(input, ?ceKEY_DOWN, list, State = #{exec_state:=ES}) -> From d7aa09939826f148b1de23a31bc93c46c0011230 Mon Sep 17 00:00:00 2001 From: Fred Hebert Date: Sat, 5 Jul 2025 17:35:04 -0400 Subject: [PATCH 27/27] make dialyzer happier --- cli/revault_cli/src/revault_cli_mod.erl | 2 +- cli/revault_cli/src/revault_curses.erl | 30 ++++++++++++++----------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/cli/revault_cli/src/revault_cli_mod.erl b/cli/revault_cli/src/revault_cli_mod.erl index 07ec3ff..0279a0d 100644 --- a/cli/revault_cli/src/revault_cli_mod.erl +++ b/cli/revault_cli/src/revault_cli_mod.erl @@ -653,7 +653,7 @@ worker_generate_keys(Parent, ReplyTo, Path, File) -> {'EXIT', Parent, Reason} -> exit(Reason); stop -> - unlink(parent), + unlink(Parent), exit(shutdown) end. diff --git a/cli/revault_cli/src/revault_curses.erl b/cli/revault_cli/src/revault_curses.erl index 1c8e967..82f6629 100644 --- a/cli/revault_cli/src/revault_curses.erl +++ b/cli/revault_cli/src/revault_curses.erl @@ -21,22 +21,30 @@ -type max_cols() :: pos_integer(). -type line() :: string(). -type lines() :: [line()]. --type arg() :: #{name := type_name(), +-type arg() :: #{name := menu_key(), label := string(), help := string(), + default := term(), type := val_type()}. -type exec_arg() :: #{type_name() := term()}. %% TODO: split internal state from callback state -type nstate() :: #{mode := menu | action | exec, + menu := menu_key() | undefined, hover_menu := menu_key(), - peer := undefined | atom(), - args := [arg(), ...], - state := state()}. + menu_map => #{menu_key() => pos()}, + menu_coord_map => #{pos() => menu_key()}, + action_args := [[arg(), ...], ...], + state := state(), + action_coords => {pos(), pos()}, + exec_coords => {pos(), pos()}, + status_coords => {pos(), pos()}, + status_init_pos => pos(), + status_message => iodata()}. -type state() :: term(). -callback menu_order() -> [menu_key(), ...]. -callback menu_help(menu_key()) -> string(). --callback args() -> #{menu_key() := arg()}. +-callback args() -> #{menu_key() := [arg(), ...]}. -callback init() -> state(). -callback render_exec(menu_key(), exec_arg(), max_lines(), max_cols(), state()) -> {render_mode(), state(), lines()}. @@ -59,6 +67,7 @@ init(Module) -> Pid = spawn_link(fun() -> main(Module) end), {ok, Pid, Module}. +-spec main(module()) -> no_return(). main(Module) -> setup(), State = state(Module, #{}), @@ -92,9 +101,6 @@ state(Mod, Old) -> mode => menu, hover_menu => hd(Mod:menu_order()), menu => undefined, - peer => undefined, - dirs => undefined, - args => #{}, state => Mod:init() }, Tmp0 = maps:merge(Default, Old), @@ -206,7 +212,7 @@ show_exec(_Mod, State=#{mode := Mode, show_exec(Mod, State=#{mode := exec, menu := Action, action_coords := {_, {ActionY,MaxX}}, - exec_args := Args, + action_args := Args, state := ModState}) -> MinY = ActionY, %% expect line-based output in a list @@ -695,7 +701,7 @@ handle_action({input, ?KEY_ENTER}, Action, TmpState = #{action_args := Args}) -> case validate_args(TmpState, Action, Args) of ok -> State = set_status(TmpState, "ok."), - {ok, State#{mode => exec, exec_args => Args}}; + {ok, State#{mode => exec}}; {error, [{#{line := {Label, _}}, Reason}|_]} -> State = set_status( TmpState, @@ -722,9 +728,7 @@ handle_exec(Mod, {Type, Msg}, Action, State=#{state := ModState}) -> {input, Char} -> io_lib:format("Unknown character in ~p: ~w", [Action, Char]); {event, _Event} -> - io_lib:format("Got unexpected event in ~p: ~p", [Action, Msg]); - _ -> - io_lib:format("Got unexpected message in ~p: ~p", [Action, Msg]) + io_lib:format("Got unexpected event in ~p: ~p", [Action, Msg]) end, {ok, set_status(State, Status)}; _ ->