% 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.