snapkitty
developer-tools
c++
hyperkitty-chromium / src /hk_cdp_client.erl
SNAPKITTYWEST's picture
Add covenant SPDX headers to every remaining unmarked source file
0196580 verified
Raw History Blame Contribute Delete
6.25 kB
% SPDX-License-Identifier: LicenseRef-Sovereign-Leviathan-SL-AGPL3-001
% Licensing: see LICENSE in the repository root.
%%%-------------------------------------------------------------------
%%% @doc HK-BROWSER :: low-level Chrome DevTools Protocol transport.
%%%
%%% One `hk_cdp_client' process owns one CDP WebSocket connection to
%%% a single browser target (tab). It is a thin JSON-RPC layer:
%%%
%%% - `command/3' sends `{"id": N, "method": M, "params": P}' and
%%% blocks the caller until the matching `{"id": N, ...}'
%%% response frame arrives (or `TimeoutMs' elapses).
%%% - Frames that carry a `"method"' instead of an `"id"' are CDP
%%% *events* (e.g. `"Page.loadEventFired"'); these are forwarded
%%% as `{cdp_event, self(), Method, Params}' to the owning
%%% `hk_browser_session' (the client's own pid is included since
%%% one session owns several clients, one per tab, and the
%%% session needs to know which tab an event belongs to), which
%%% is what lets navigation completion be observed rather than
%%% guessed at with a sleep.
%%%
%%% This module deliberately knows nothing about HyperKitty's typed
%%% browser operations (navigate/click/type/...) -- that mapping
%%% lives in `hk_browser_session'. Keeping the CDP transport and the
%%% typed capability surface in separate modules is what the spec's
%%% "treat Chromium as an execution substrate controlled through a
%%% well-defined browser protocol rather than mixing browser state
%%% directly into application state" requirement means concretely.
%%% @end
%%%-------------------------------------------------------------------
-module(hk_cdp_client).
-behaviour(gen_server).
-export([start_link/2, command/3, command/4, close/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
-record(state, {
owner :: pid(),
conn :: pid(),
stream_ref :: reference(),
next_id = 1 :: pos_integer(),
pending = #{} :: #{pos_integer() => {pid(), reference()}}
}).
%% @doc Connect to a target's `webSocketDebuggerUrl' (as returned by
%% Chrome's `/json/new' HTTP endpoint). `Owner' receives
%% `{cdp_event, Method, Params}' for every unsolicited CDP event.
-spec start_link(binary(), pid()) -> {ok, pid()} | {error, term()}.
start_link(WsUrl, Owner) ->
gen_server:start_link(?MODULE, {WsUrl, Owner}, []).
-spec command(pid(), binary(), map()) -> {ok, map()} | {error, term()}.
command(Pid, Method, Params) ->
command(Pid, Method, Params, 15000).
-spec command(pid(), binary(), map(), timeout()) -> {ok, map()} | {error, term()}.
command(Pid, Method, Params, TimeoutMs) ->
try
gen_server:call(Pid, {command, Method, Params}, TimeoutMs)
catch
exit:{timeout, _} -> {error, cdp_timeout}
end.
-spec close(pid()) -> ok.
close(Pid) ->
gen_server:cast(Pid, close).
%%% gen_server callbacks
init({WsUrl, Owner}) ->
case parse_ws_url(WsUrl) of
{ok, Host, Port, Path} ->
case gun:open(Host, Port, #{protocols => [http]}) of
{ok, ConnPid} ->
{ok, _} = gun:await_up(ConnPid, 10000),
StreamRef = gun:ws_upgrade(ConnPid, Path),
receive
{gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} ->
{ok, #state{owner = Owner, conn = ConnPid, stream_ref = StreamRef}};
{gun_response, ConnPid, _, _, Status, _} ->
{stop, {ws_upgrade_failed, Status}};
{gun_error, ConnPid, StreamRef, Reason} ->
{stop, {ws_upgrade_error, Reason}}
after 10000 ->
{stop, ws_upgrade_timeout}
end;
{error, Reason} ->
{stop, {connect_failed, Reason}}
end;
{error, Reason} ->
{stop, {bad_ws_url, Reason}}
end.
handle_call({command, Method, Params}, From, State = #state{next_id = Id, pending = Pending}) ->
Frame = jsx:encode(#{id => Id, method => Method, params => Params}),
gun:ws_send(State#state.conn, State#state.stream_ref, {text, Frame}),
{noreply, State#state{next_id = Id + 1, pending = Pending#{Id => From}}}.
handle_cast(close, State) ->
gun:close(State#state.conn),
{stop, normal, State}.
handle_info({gun_ws, ConnPid, StreamRef, {text, Frame}},
State = #state{conn = ConnPid, stream_ref = StreamRef}) ->
handle_frame(jsx:decode(Frame, [return_maps]), State);
handle_info({gun_down, ConnPid, _, _Reason, _}, State = #state{conn = ConnPid}) ->
fail_all_pending(State#state.pending, cdp_connection_down),
State#state.owner ! {cdp_event, self(), <<"__connection_down">>, #{}},
{noreply, State#state{pending = #{}}};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, State) ->
fail_all_pending(State#state.pending, cdp_client_terminated),
ok.
%%% internal
handle_frame(#{<<"id">> := Id} = Msg, State = #state{pending = Pending}) ->
case maps:take(Id, Pending) of
{From, Pending2} ->
Reply = case Msg of
#{<<"error">> := Err} -> {error, Err};
#{<<"result">> := Result} -> {ok, Result};
_ -> {ok, #{}}
end,
gen_server:reply(From, Reply),
{noreply, State#state{pending = Pending2}};
error ->
{noreply, State}
end;
handle_frame(#{<<"method">> := Method} = Msg, State) ->
Params = maps:get(<<"params">>, Msg, #{}),
State#state.owner ! {cdp_event, self(), Method, Params},
{noreply, State};
handle_frame(_Other, State) ->
{noreply, State}.
fail_all_pending(Pending, Reason) ->
maps:foreach(fun(_Id, From) -> gen_server:reply(From, {error, Reason}) end, Pending).
parse_ws_url(Url) ->
case uri_string:parse(Url) of
#{host := Host, port := Port, path := Path} ->
{ok, binary_to_list(Host), Port, binary_to_list(Path)};
#{host := Host, path := Path} ->
{ok, binary_to_list(Host), 80, binary_to_list(Path)};
{error, Reason, _} ->
{error, Reason}
end.