snapkitty
developer-tools
c++
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.