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