Download src/hk_browser_session.erl from Snapkitty/hyperkitty-chromium: direct link, hf CLI and curl.
- Browser
- Download file 18.5 kB
-
https://huggingface.co/Snapkitty/hyperkitty-chromium/resolve/main/src/hk_browser_session.erl
- Command line
-
hf download hf://Snapkitty/hyperkitty-chromium/src/hk_browser_session.erl
-
curl -L -o hk_browser_session.erl https://huggingface.co/Snapkitty/hyperkitty-chromium/resolve/main/src/hk_browser_session.erl
18.5 kB
| % SPDX-License-Identifier: LicenseRef-Sovereign-Leviathan-SL-AGPL3-001 | |
| % Licensing: see LICENSE in the repository root. | |
| %%%------------------------------------------------------------------- | |
| %%% @doc HK-BROWSER :: Chromium Controller (one process per session). | |
| %%% | |
| %%% One `hk_browser_session' gen_server owns exactly one headless | |
| %%% Chromium OS process (its own `--user-data-dir', its own | |
| %%% `--remote-debugging-port') and every tab opened within it. This | |
| %%% is the process boundary the architecture calls for: browser | |
| %%% state (which tabs exist, what URL each is on) lives *only* here, | |
| %%% reached exclusively through the typed operations below -- never | |
| %%% mixed into agent state or read directly from another process. | |
| %%% | |
| %%% Every operation: | |
| %%% 1. is validated (tab exists, session is `ready') before any | |
| %%% Chromium interaction is attempted; | |
| %%% 2. drives Chromium through `hk_cdp_client' (never through ad | |
| %%% hoc process state); | |
| %%% 3. publishes a structured `hk_event' recording what happened, | |
| %%% win or lose -- per the agent_browser_interface requirement | |
| %%% that "every operation must produce a structured event." | |
| %%% | |
| %%% Click/type are implemented via `Runtime.evaluate' (DOM method | |
| %%% calls and property assignment) rather than CDP's `Input' domain | |
| %%% (synthetic OS-level mouse/keyboard events). This is a documented | |
| %%% simplification, not a hidden one: it's adequate for | |
| %%% programmatic interaction with most pages, but it will not | |
| %%% exercise a page's native pointer/keyboard event handlers the way | |
| %%% `Input.dispatchMouseEvent'/`Input.dispatchKeyEvent' would. A | |
| %%% production build would add an `Input'-domain code path behind | |
| %%% the same typed operation names. | |
| %%% @end | |
| %%%------------------------------------------------------------------- | |
| -module(hk_browser_session). | |
| -behaviour(gen_server). | |
| -include("hyperkitty.hrl"). | |
| -export([start_link/1]). | |
| -export([open_tab/2, close_tab/2, navigate/3, back/2, forward/2, reload/2, | |
| read_page/2, query_element/3, click/3, type/4, scroll/3, | |
| screenshot/2, extract_links/2, close_session/1, describe/1]). | |
| -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]). | |
| -define(CHROME_READY_TIMEOUT_MS, 15000). | |
| -define(NAV_TIMEOUT_MS, 30000). | |
| -record(tab, { | |
| tab_id :: binary(), | |
| target_id :: binary(), | |
| cdp_pid :: pid(), | |
| url :: binary() | undefined, | |
| title :: binary() | undefined, | |
| status :: opening | loading | idle | closed, | |
| created_at_ms :: integer(), | |
| updated_at_ms :: integer() | |
| }). | |
| -record(state, { | |
| session_id :: binary(), | |
| owner_agent_id :: binary() | undefined, | |
| profile :: binary(), | |
| debug_port :: inet:port_number(), | |
| user_data_dir :: string(), | |
| os_port :: port(), | |
| status = starting :: starting | ready | busy | closing | closed | crashed, | |
| tabs = #{} :: #{binary() => #tab{}}, | |
| cdp_pid_to_tab = #{} :: #{pid() => binary()}, | |
| waiters = #{} :: #{{binary(), atom()} => gen_server:from()}, | |
| created_at_ms :: integer() | |
| }). | |
| %% Start args: #{session_id, owner_agent_id, profile} | |
| start_link(Args) -> | |
| gen_server:start_link(?MODULE, Args, []). | |
| open_tab(Pid, Url) -> gen_server:call(Pid, {open_tab, Url}, ?CHROME_READY_TIMEOUT_MS). | |
| close_tab(Pid, TabId) -> gen_server:call(Pid, {close_tab, TabId}). | |
| navigate(Pid, TabId, Url) -> gen_server:call(Pid, {navigate, TabId, Url}, ?NAV_TIMEOUT_MS). | |
| back(Pid, TabId) -> gen_server:call(Pid, {history, TabId, back}, ?NAV_TIMEOUT_MS). | |
| forward(Pid, TabId) -> gen_server:call(Pid, {history, TabId, forward}, ?NAV_TIMEOUT_MS). | |
| reload(Pid, TabId) -> gen_server:call(Pid, {reload, TabId}, ?NAV_TIMEOUT_MS). | |
| read_page(Pid, TabId) -> gen_server:call(Pid, {read_page, TabId}). | |
| query_element(Pid, TabId, Selector) -> gen_server:call(Pid, {query_element, TabId, Selector}). | |
| click(Pid, TabId, Selector) -> gen_server:call(Pid, {click, TabId, Selector}). | |
| type(Pid, TabId, Selector, Text) -> gen_server:call(Pid, {type, TabId, Selector, Text}). | |
| scroll(Pid, TabId, DeltaY) -> gen_server:call(Pid, {scroll, TabId, DeltaY}). | |
| screenshot(Pid, TabId) -> gen_server:call(Pid, {screenshot, TabId}). | |
| extract_links(Pid, TabId) -> gen_server:call(Pid, {extract_links, TabId}). | |
| close_session(Pid) -> gen_server:call(Pid, close_session). | |
| describe(Pid) -> gen_server:call(Pid, describe). | |
| %%% gen_server callbacks | |
| init(#{session_id := SessionId} = Args) -> | |
| process_flag(trap_exit, true), | |
| Profile = maps:get(profile, Args, <<"default">>), | |
| OwnerAgentId = maps:get(owner_agent_id, Args, undefined), | |
| Port = find_free_port(), | |
| UserDataDir = filename:join(["/tmp/hyperkitty_profiles", binary_to_list(SessionId)]), | |
| ok = filelib:ensure_dir(UserDataDir ++ "/."), | |
| ChromeExe = application:get_env(hyperkitty, chromium_executable, | |
| "/usr/bin/chromium"), | |
| Args0 = [ | |
| "--headless=new", | |
| "--remote-debugging-port=" ++ integer_to_list(Port), | |
| "--remote-debugging-address=127.0.0.1", | |
| "--user-data-dir=" ++ UserDataDir, | |
| "--no-sandbox", | |
| "--disable-gpu", | |
| "--disable-dev-shm-usage", | |
| "about:blank" | |
| ], | |
| OsPort = erlang:open_port({spawn_executable, ChromeExe}, | |
| [{args, Args0}, exit_status, binary, stderr_to_stdout, | |
| {line, 4096}]), | |
| State = #state{ | |
| session_id = SessionId, | |
| owner_agent_id = OwnerAgentId, | |
| profile = Profile, | |
| debug_port = Port, | |
| user_data_dir = UserDataDir, | |
| os_port = OsPort, | |
| created_at_ms = hk_id:timestamp_ms() | |
| }, | |
| case hk_chrome_http:wait_for_ready(Port, ?CHROME_READY_TIMEOUT_MS) of | |
| ok -> | |
| ok = hk_browser_registry:register(SessionId, self()), | |
| emit(State, <<"browser.session.created">>, #{profile => Profile, debug_port => Port}), | |
| {ok, State#state{status = ready}}; | |
| {error, timeout} -> | |
| catch erlang:port_close(OsPort), | |
| {stop, chrome_launch_timeout} | |
| end. | |
| handle_call(describe, _From, State) -> | |
| {reply, {ok, session_map(State)}, State}; | |
| handle_call({open_tab, Url}, _From, State = #state{status = ready}) -> | |
| case hk_chrome_http:new_target(State#state.debug_port, Url) of | |
| {ok, #{target_id := TargetId, ws_url := WsUrl}} -> | |
| {ok, CdpPid} = hk_cdp_client:start_link(WsUrl, self()), | |
| _ = hk_cdp_client:command(CdpPid, <<"Page.enable">>, #{}), | |
| _ = hk_cdp_client:command(CdpPid, <<"Runtime.enable">>, #{}), | |
| TabId = hk_id:new(<<"tab">>), | |
| Now = hk_id:timestamp_ms(), | |
| Tab = #tab{tab_id = TabId, target_id = TargetId, cdp_pid = CdpPid, | |
| url = Url, status = loading, created_at_ms = Now, updated_at_ms = Now}, | |
| State2 = State#state{ | |
| tabs = maps:put(TabId, Tab, State#state.tabs), | |
| cdp_pid_to_tab = maps:put(CdpPid, TabId, State#state.cdp_pid_to_tab) | |
| }, | |
| emit(State2, <<"browser.tab.opened">>, #{tab_id => TabId, url => Url}), | |
| {reply, {ok, TabId}, State2}; | |
| {error, Reason} -> | |
| emit(State, <<"browser.tab.open_failed">>, #{url => Url, reason => term_bin(Reason)}), | |
| {reply, {error, Reason}, State} | |
| end; | |
| handle_call({close_tab, TabId}, _From, State) -> | |
| case maps:find(TabId, State#state.tabs) of | |
| {ok, Tab} -> | |
| hk_cdp_client:close(Tab#tab.cdp_pid), | |
| _ = hk_chrome_http:close_target(State#state.debug_port, Tab#tab.target_id), | |
| State2 = drop_tab(TabId, Tab, State), | |
| emit(State2, <<"browser.tab.closed">>, #{tab_id => TabId}), | |
| {reply, ok, State2}; | |
| error -> | |
| {reply, {error, no_such_tab}, State} | |
| end; | |
| handle_call({navigate, TabId, Url}, From, State) -> | |
| with_tab(TabId, State, fun(Tab) -> | |
| emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, url => Url}), | |
| _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigate">>, #{<<"url">> => Url}), | |
| Tab2 = Tab#tab{url = Url, status = loading, updated_at_ms = hk_id:timestamp_ms()}, | |
| State2 = put_tab(Tab2, State), | |
| Key = {TabId, load_event}, | |
| {noreply, State2#state{waiters = maps:put(Key, From, State2#state.waiters)}} | |
| end); | |
| handle_call({history, TabId, Direction}, From, State) -> | |
| with_tab(TabId, State, fun(Tab) -> | |
| {ok, HistResult} = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.getNavigationHistory">>, #{}), | |
| #{<<"currentIndex">> := Idx, <<"entries">> := Entries} = HistResult, | |
| TargetIdx = case Direction of back -> Idx - 1; forward -> Idx + 1 end, | |
| case TargetIdx >= 0 andalso TargetIdx < length(Entries) of | |
| true -> | |
| Entry = lists:nth(TargetIdx + 1, Entries), | |
| EntryId = maps:get(<<"id">>, Entry), | |
| _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigateToHistoryEntry">>, | |
| #{<<"entryId">> => EntryId}), | |
| emit(State, <<"browser.navigation.started">>, | |
| #{tab_id => TabId, direction => Direction}), | |
| Key = {TabId, load_event}, | |
| {noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}}; | |
| false -> | |
| {reply, {error, no_history_entry}, State} | |
| end | |
| end); | |
| handle_call({reload, TabId}, From, State) -> | |
| with_tab(TabId, State, fun(Tab) -> | |
| _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.reload">>, #{}), | |
| emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, reload => true}), | |
| Key = {TabId, load_event}, | |
| {noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}} | |
| end); | |
| handle_call({read_page, TabId}, _From, State) -> | |
| with_tab_sync(TabId, State, fun(Tab) -> | |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, | |
| #{<<"expression">> => <<"document.body ? document.body.innerText : ''">>, | |
| <<"returnByValue">> => true})) | |
| end, fun(Result) -> | |
| emit(State, <<"browser.page.read">>, #{tab_id => TabId, chars => text_len(Result)}) | |
| end); | |
| handle_call({query_element, TabId, Selector}, _From, State) -> | |
| Expr = query_element_js(Selector), | |
| with_tab_sync(TabId, State, fun(Tab) -> | |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, | |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) | |
| end, fun(_Result) -> | |
| emit(State, <<"browser.element.queried">>, #{tab_id => TabId, selector => Selector}) | |
| end); | |
| handle_call({click, TabId, Selector}, _From, State) -> | |
| Expr = click_js(Selector), | |
| with_tab_sync(TabId, State, fun(Tab) -> | |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, | |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) | |
| end, fun(_Result) -> | |
| emit(State, <<"browser.action.click">>, #{tab_id => TabId, selector => Selector}) | |
| end); | |
| handle_call({type, TabId, Selector, Text}, _From, State) -> | |
| Expr = type_js(Selector, Text), | |
| with_tab_sync(TabId, State, fun(Tab) -> | |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, | |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) | |
| end, fun(_Result) -> | |
| emit(State, <<"browser.action.type">>, #{tab_id => TabId, selector => Selector, | |
| chars => byte_size(Text)}) | |
| end); | |
| handle_call({scroll, TabId, DeltaY}, _From, State) -> | |
| Expr = list_to_binary(io_lib:format("window.scrollBy(0, ~w); 'ok'", [DeltaY])), | |
| with_tab_sync(TabId, State, fun(Tab) -> | |
| eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, | |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) | |
| end, fun(_Result) -> | |
| emit(State, <<"browser.action.scroll">>, #{tab_id => TabId, delta_y => DeltaY}) | |
| end); | |
| handle_call({screenshot, TabId}, _From, State) -> | |
| with_tab_sync(TabId, State, fun(Tab) -> | |
| case hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.captureScreenshot">>, | |
| #{<<"format">> => <<"png">>}) of | |
| {ok, #{<<"data">> := B64}} -> {ok, B64}; | |
| {error, Reason} -> {error, Reason} | |
| end | |
| end, fun(_Result) -> | |
| emit(State, <<"browser.screenshot.captured">>, #{tab_id => TabId}) | |
| end); | |
| handle_call({extract_links, TabId}, _From, State) -> | |
| Expr = <<"JSON.stringify(Array.from(document.querySelectorAll('a[href]'))" | |
| ".map(a => ({href: a.href, text: a.textContent.trim()})))">>, | |
| with_tab_sync(TabId, State, fun(Tab) -> | |
| case eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>, | |
| #{<<"expression">> => Expr, <<"returnByValue">> => true})) of | |
| {ok, Json} when is_binary(Json) -> | |
| try {ok, jsx:decode(Json, [return_maps])} | |
| catch _:_ -> {ok, []} end; | |
| Other -> Other | |
| end | |
| end, fun(Result) -> | |
| Count = case Result of L when is_list(L) -> length(L); _ -> 0 end, | |
| emit(State, <<"browser.links.extracted">>, #{tab_id => TabId, count => Count}) | |
| end); | |
| handle_call(close_session, _From, State) -> | |
| State2 = State#state{status = closing}, | |
| maps:foreach(fun(_TabId, Tab) -> hk_cdp_client:close(Tab#tab.cdp_pid) end, State2#state.tabs), | |
| catch erlang:port_close(State2#state.os_port), | |
| emit(State2, <<"browser.session.closed">>, #{}), | |
| {stop, normal, ok, State2#state{status = closed}}. | |
| handle_cast(_Msg, State) -> | |
| {noreply, State}. | |
| %% CDP event fan-in: correlate `Page.loadEventFired' back to whichever | |
| %% navigate/back/forward/reload call is waiting on this tab. | |
| handle_info({cdp_event, CdpPid, <<"Page.loadEventFired">>, _Params}, State) -> | |
| case maps:find(CdpPid, State#state.cdp_pid_to_tab) of | |
| {ok, TabId} -> | |
| Key = {TabId, load_event}, | |
| case maps:take(Key, State#state.waiters) of | |
| {From, Waiters2} -> | |
| emit(State, <<"browser.navigation.completed">>, #{tab_id => TabId}), | |
| gen_server:reply(From, ok), | |
| {noreply, State#state{waiters = Waiters2}}; | |
| error -> | |
| {noreply, State} | |
| end; | |
| error -> | |
| {noreply, State} | |
| end; | |
| handle_info({cdp_event, _CdpPid, _Method, _Params}, State) -> | |
| {noreply, State}; %% other CDP events are available for future views (console, network, ...) | |
| handle_info({Port, {exit_status, Status}}, State = #state{os_port = Port, status = St}) | |
| when St =/= closing -> | |
| emit(State, <<"browser.session.crashed">>, #{exit_status => Status}), | |
| {stop, {error, {chrome_process_exited, Status}}, State#state{status = crashed}}; | |
| handle_info({Port, {exit_status, _Status}}, State = #state{os_port = Port}) -> | |
| {noreply, State}; | |
| handle_info({Port, {data, _}}, State = #state{os_port = Port}) -> | |
| {noreply, State}; | |
| handle_info(_Info, State) -> | |
| {noreply, State}. | |
| terminate(_Reason, _State) -> | |
| ok. | |
| %%% internal | |
| find_free_port() -> | |
| {ok, Socket} = gen_tcp:listen(0, [{reuseaddr, true}]), | |
| {ok, Port} = inet:port(Socket), | |
| ok = gen_tcp:close(Socket), | |
| Port. | |
| with_tab(TabId, State, Fun) -> | |
| case maps:find(TabId, State#state.tabs) of | |
| {ok, Tab} -> Fun(Tab); | |
| error -> {reply, {error, no_such_tab}, State} | |
| end. | |
| %% For operations that complete synchronously against the CDP client | |
| %% (i.e. not waiting on a later async event like page load): run | |
| %% `EvalFun(Tab) -> {ok, Result} | {error, Reason}', emit `OnOk(Result)' | |
| %% only on success, and always reply from this call. | |
| with_tab_sync(TabId, State, EvalFun, OnOk) -> | |
| case maps:find(TabId, State#state.tabs) of | |
| {ok, Tab} -> | |
| case EvalFun(Tab) of | |
| {ok, Result} -> | |
| OnOk(Result), | |
| {reply, {ok, Result}, State}; | |
| {error, Reason} -> | |
| emit(State, <<"browser.action.failed">>, | |
| #{tab_id => TabId, reason => term_bin(Reason)}), | |
| {reply, {error, Reason}, State} | |
| end; | |
| error -> | |
| {reply, {error, no_such_tab}, State} | |
| end. | |
| eval_result({ok, #{<<"exceptionDetails">> := Details}}) -> | |
| {error, {js_exception, Details}}; | |
| eval_result({ok, #{<<"result">> := #{<<"value">> := Value}}}) -> | |
| {ok, Value}; | |
| eval_result({ok, #{<<"result">> := _}}) -> | |
| {ok, undefined}; | |
| eval_result({error, Reason}) -> | |
| {error, Reason}. | |
| query_element_js(Selector) -> | |
| SelJson = jsx:encode(Selector), | |
| <<"(() => { const el = document.querySelector(", SelJson/binary, "); " | |
| "return el ? JSON.stringify({exists:true, tag: el.tagName, " | |
| "text:(el.textContent||'').trim().slice(0,500)}) : " | |
| "JSON.stringify({exists:false}); })()">>. | |
| click_js(Selector) -> | |
| SelJson = jsx:encode(Selector), | |
| <<"(() => { const el = document.querySelector(", SelJson/binary, "); " | |
| "if (!el) return 'not_found'; el.click(); return 'ok'; })()">>. | |
| type_js(Selector, Text) -> | |
| SelJson = jsx:encode(Selector), | |
| TextJson = jsx:encode(Text), | |
| <<"(() => { const el = document.querySelector(", SelJson/binary, "); " | |
| "if (!el) return 'not_found'; el.focus(); el.value = ", TextJson/binary, "; " | |
| "el.dispatchEvent(new Event('input', {bubbles:true})); " | |
| "el.dispatchEvent(new Event('change', {bubbles:true})); return 'ok'; })()">>. | |
| put_tab(Tab, State) -> | |
| State#state{tabs = maps:put(Tab#tab.tab_id, Tab, State#state.tabs)}. | |
| drop_tab(TabId, Tab, State) -> | |
| State#state{ | |
| tabs = maps:remove(TabId, State#state.tabs), | |
| cdp_pid_to_tab = maps:remove(Tab#tab.cdp_pid, State#state.cdp_pid_to_tab) | |
| }. | |
| session_map(State) -> | |
| #browser_session{ | |
| session_id = State#state.session_id, | |
| owner_agent_id = State#state.owner_agent_id, | |
| profile = State#state.profile, | |
| status = State#state.status, | |
| tabs = maps:keys(State#state.tabs), | |
| created_at_ms = State#state.created_at_ms, | |
| updated_at_ms = hk_id:timestamp_ms() | |
| }. | |
| emit(State, Category, Data) -> | |
| Event = hk_event:new(Category, {browser_session, State#state.session_id}, Data), | |
| catch hk_event_bus:publish(Event). | |
| term_bin(Term) -> | |
| iolist_to_binary(io_lib:format("~p", [Term])). | |
| text_len(B) when is_binary(B) -> byte_size(B); | |
| text_len(_) -> 0. | |