File size: 6,254 Bytes
0196580 ceece2e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | % 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.
|