snapkitty
developer-tools
c++
SNAPKITTYWEST commited on
Commit
a347219
·
verified ·
1 Parent(s): d44946a

Add src/hk_browser_session.erl

Browse files
Files changed (1) hide show
  1. src/hk_browser_session.erl +415 -0
src/hk_browser_session.erl ADDED
@@ -0,0 +1,415 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %%%-------------------------------------------------------------------
2
+ %%% @doc HK-BROWSER :: Chromium Controller (one process per session).
3
+ %%%
4
+ %%% One `hk_browser_session' gen_server owns exactly one headless
5
+ %%% Chromium OS process (its own `--user-data-dir', its own
6
+ %%% `--remote-debugging-port') and every tab opened within it. This
7
+ %%% is the process boundary the architecture calls for: browser
8
+ %%% state (which tabs exist, what URL each is on) lives *only* here,
9
+ %%% reached exclusively through the typed operations below -- never
10
+ %%% mixed into agent state or read directly from another process.
11
+ %%%
12
+ %%% Every operation:
13
+ %%% 1. is validated (tab exists, session is `ready') before any
14
+ %%% Chromium interaction is attempted;
15
+ %%% 2. drives Chromium through `hk_cdp_client' (never through ad
16
+ %%% hoc process state);
17
+ %%% 3. publishes a structured `hk_event' recording what happened,
18
+ %%% win or lose -- per the agent_browser_interface requirement
19
+ %%% that "every operation must produce a structured event."
20
+ %%%
21
+ %%% Click/type are implemented via `Runtime.evaluate' (DOM method
22
+ %%% calls and property assignment) rather than CDP's `Input' domain
23
+ %%% (synthetic OS-level mouse/keyboard events). This is a documented
24
+ %%% simplification, not a hidden one: it's adequate for
25
+ %%% programmatic interaction with most pages, but it will not
26
+ %%% exercise a page's native pointer/keyboard event handlers the way
27
+ %%% `Input.dispatchMouseEvent'/`Input.dispatchKeyEvent' would. A
28
+ %%% production build would add an `Input'-domain code path behind
29
+ %%% the same typed operation names.
30
+ %%% @end
31
+ %%%-------------------------------------------------------------------
32
+ -module(hk_browser_session).
33
+ -behaviour(gen_server).
34
+
35
+ -include("hyperkitty.hrl").
36
+
37
+ -export([start_link/1]).
38
+ -export([open_tab/2, close_tab/2, navigate/3, back/2, forward/2, reload/2,
39
+ read_page/2, query_element/3, click/3, type/4, scroll/3,
40
+ screenshot/2, extract_links/2, close_session/1, describe/1]).
41
+ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2]).
42
+
43
+ -define(CHROME_READY_TIMEOUT_MS, 15000).
44
+ -define(NAV_TIMEOUT_MS, 30000).
45
+
46
+ -record(tab, {
47
+ tab_id :: binary(),
48
+ target_id :: binary(),
49
+ cdp_pid :: pid(),
50
+ url :: binary() | undefined,
51
+ title :: binary() | undefined,
52
+ status :: opening | loading | idle | closed,
53
+ created_at_ms :: integer(),
54
+ updated_at_ms :: integer()
55
+ }).
56
+
57
+ -record(state, {
58
+ session_id :: binary(),
59
+ owner_agent_id :: binary() | undefined,
60
+ profile :: binary(),
61
+ debug_port :: inet:port_number(),
62
+ user_data_dir :: string(),
63
+ os_port :: port(),
64
+ status = starting :: starting | ready | busy | closing | closed | crashed,
65
+ tabs = #{} :: #{binary() => #tab{}},
66
+ cdp_pid_to_tab = #{} :: #{pid() => binary()},
67
+ waiters = #{} :: #{{binary(), atom()} => gen_server:from()},
68
+ created_at_ms :: integer()
69
+ }).
70
+
71
+ %% Start args: #{session_id, owner_agent_id, profile}
72
+ start_link(Args) ->
73
+ gen_server:start_link(?MODULE, Args, []).
74
+
75
+ open_tab(Pid, Url) -> gen_server:call(Pid, {open_tab, Url}, ?CHROME_READY_TIMEOUT_MS).
76
+ close_tab(Pid, TabId) -> gen_server:call(Pid, {close_tab, TabId}).
77
+ navigate(Pid, TabId, Url) -> gen_server:call(Pid, {navigate, TabId, Url}, ?NAV_TIMEOUT_MS).
78
+ back(Pid, TabId) -> gen_server:call(Pid, {history, TabId, back}, ?NAV_TIMEOUT_MS).
79
+ forward(Pid, TabId) -> gen_server:call(Pid, {history, TabId, forward}, ?NAV_TIMEOUT_MS).
80
+ reload(Pid, TabId) -> gen_server:call(Pid, {reload, TabId}, ?NAV_TIMEOUT_MS).
81
+ read_page(Pid, TabId) -> gen_server:call(Pid, {read_page, TabId}).
82
+ query_element(Pid, TabId, Selector) -> gen_server:call(Pid, {query_element, TabId, Selector}).
83
+ click(Pid, TabId, Selector) -> gen_server:call(Pid, {click, TabId, Selector}).
84
+ type(Pid, TabId, Selector, Text) -> gen_server:call(Pid, {type, TabId, Selector, Text}).
85
+ scroll(Pid, TabId, DeltaY) -> gen_server:call(Pid, {scroll, TabId, DeltaY}).
86
+ screenshot(Pid, TabId) -> gen_server:call(Pid, {screenshot, TabId}).
87
+ extract_links(Pid, TabId) -> gen_server:call(Pid, {extract_links, TabId}).
88
+ close_session(Pid) -> gen_server:call(Pid, close_session).
89
+ describe(Pid) -> gen_server:call(Pid, describe).
90
+
91
+ %%% gen_server callbacks
92
+
93
+ init(#{session_id := SessionId} = Args) ->
94
+ process_flag(trap_exit, true),
95
+ Profile = maps:get(profile, Args, <<"default">>),
96
+ OwnerAgentId = maps:get(owner_agent_id, Args, undefined),
97
+ Port = find_free_port(),
98
+ UserDataDir = filename:join(["/tmp/hyperkitty_profiles", binary_to_list(SessionId)]),
99
+ ok = filelib:ensure_dir(UserDataDir ++ "/."),
100
+ ChromeExe = application:get_env(hyperkitty, chromium_executable,
101
+ "/usr/bin/chromium"),
102
+ Args0 = [
103
+ "--headless=new",
104
+ "--remote-debugging-port=" ++ integer_to_list(Port),
105
+ "--remote-debugging-address=127.0.0.1",
106
+ "--user-data-dir=" ++ UserDataDir,
107
+ "--no-sandbox",
108
+ "--disable-gpu",
109
+ "--disable-dev-shm-usage",
110
+ "about:blank"
111
+ ],
112
+ OsPort = erlang:open_port({spawn_executable, ChromeExe},
113
+ [{args, Args0}, exit_status, binary, stderr_to_stdout,
114
+ {line, 4096}]),
115
+ State = #state{
116
+ session_id = SessionId,
117
+ owner_agent_id = OwnerAgentId,
118
+ profile = Profile,
119
+ debug_port = Port,
120
+ user_data_dir = UserDataDir,
121
+ os_port = OsPort,
122
+ created_at_ms = hk_id:timestamp_ms()
123
+ },
124
+ case hk_chrome_http:wait_for_ready(Port, ?CHROME_READY_TIMEOUT_MS) of
125
+ ok ->
126
+ ok = hk_browser_registry:register(SessionId, self()),
127
+ emit(State, <<"browser.session.created">>, #{profile => Profile, debug_port => Port}),
128
+ {ok, State#state{status = ready}};
129
+ {error, timeout} ->
130
+ catch erlang:port_close(OsPort),
131
+ {stop, chrome_launch_timeout}
132
+ end.
133
+
134
+ handle_call(describe, _From, State) ->
135
+ {reply, {ok, session_map(State)}, State};
136
+
137
+ handle_call({open_tab, Url}, _From, State = #state{status = ready}) ->
138
+ case hk_chrome_http:new_target(State#state.debug_port, Url) of
139
+ {ok, #{target_id := TargetId, ws_url := WsUrl}} ->
140
+ {ok, CdpPid} = hk_cdp_client:start_link(WsUrl, self()),
141
+ _ = hk_cdp_client:command(CdpPid, <<"Page.enable">>, #{}),
142
+ _ = hk_cdp_client:command(CdpPid, <<"Runtime.enable">>, #{}),
143
+ TabId = hk_id:new(<<"tab">>),
144
+ Now = hk_id:timestamp_ms(),
145
+ Tab = #tab{tab_id = TabId, target_id = TargetId, cdp_pid = CdpPid,
146
+ url = Url, status = loading, created_at_ms = Now, updated_at_ms = Now},
147
+ State2 = State#state{
148
+ tabs = maps:put(TabId, Tab, State#state.tabs),
149
+ cdp_pid_to_tab = maps:put(CdpPid, TabId, State#state.cdp_pid_to_tab)
150
+ },
151
+ emit(State2, <<"browser.tab.opened">>, #{tab_id => TabId, url => Url}),
152
+ {reply, {ok, TabId}, State2};
153
+ {error, Reason} ->
154
+ emit(State, <<"browser.tab.open_failed">>, #{url => Url, reason => term_bin(Reason)}),
155
+ {reply, {error, Reason}, State}
156
+ end;
157
+
158
+ handle_call({close_tab, TabId}, _From, State) ->
159
+ case maps:find(TabId, State#state.tabs) of
160
+ {ok, Tab} ->
161
+ hk_cdp_client:close(Tab#tab.cdp_pid),
162
+ _ = hk_chrome_http:close_target(State#state.debug_port, Tab#tab.target_id),
163
+ State2 = drop_tab(TabId, Tab, State),
164
+ emit(State2, <<"browser.tab.closed">>, #{tab_id => TabId}),
165
+ {reply, ok, State2};
166
+ error ->
167
+ {reply, {error, no_such_tab}, State}
168
+ end;
169
+
170
+ handle_call({navigate, TabId, Url}, From, State) ->
171
+ with_tab(TabId, State, fun(Tab) ->
172
+ emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, url => Url}),
173
+ _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigate">>, #{<<"url">> => Url}),
174
+ Tab2 = Tab#tab{url = Url, status = loading, updated_at_ms = hk_id:timestamp_ms()},
175
+ State2 = put_tab(Tab2, State),
176
+ Key = {TabId, load_event},
177
+ {noreply, State2#state{waiters = maps:put(Key, From, State2#state.waiters)}}
178
+ end);
179
+
180
+ handle_call({history, TabId, Direction}, From, State) ->
181
+ with_tab(TabId, State, fun(Tab) ->
182
+ {ok, HistResult} = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.getNavigationHistory">>, #{}),
183
+ #{<<"currentIndex">> := Idx, <<"entries">> := Entries} = HistResult,
184
+ TargetIdx = case Direction of back -> Idx - 1; forward -> Idx + 1 end,
185
+ case TargetIdx >= 0 andalso TargetIdx < length(Entries) of
186
+ true ->
187
+ Entry = lists:nth(TargetIdx + 1, Entries),
188
+ EntryId = maps:get(<<"id">>, Entry),
189
+ _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.navigateToHistoryEntry">>,
190
+ #{<<"entryId">> => EntryId}),
191
+ emit(State, <<"browser.navigation.started">>,
192
+ #{tab_id => TabId, direction => Direction}),
193
+ Key = {TabId, load_event},
194
+ {noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}};
195
+ false ->
196
+ {reply, {error, no_history_entry}, State}
197
+ end
198
+ end);
199
+
200
+ handle_call({reload, TabId}, From, State) ->
201
+ with_tab(TabId, State, fun(Tab) ->
202
+ _ = hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.reload">>, #{}),
203
+ emit(State, <<"browser.navigation.started">>, #{tab_id => TabId, reload => true}),
204
+ Key = {TabId, load_event},
205
+ {noreply, State#state{waiters = maps:put(Key, From, State#state.waiters)}}
206
+ end);
207
+
208
+ handle_call({read_page, TabId}, _From, State) ->
209
+ with_tab_sync(TabId, State, fun(Tab) ->
210
+ eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
211
+ #{<<"expression">> => <<"document.body ? document.body.innerText : ''">>,
212
+ <<"returnByValue">> => true}))
213
+ end, fun(Result) ->
214
+ emit(State, <<"browser.page.read">>, #{tab_id => TabId, chars => text_len(Result)})
215
+ end);
216
+
217
+ handle_call({query_element, TabId, Selector}, _From, State) ->
218
+ Expr = query_element_js(Selector),
219
+ with_tab_sync(TabId, State, fun(Tab) ->
220
+ eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
221
+ #{<<"expression">> => Expr, <<"returnByValue">> => true}))
222
+ end, fun(_Result) ->
223
+ emit(State, <<"browser.element.queried">>, #{tab_id => TabId, selector => Selector})
224
+ end);
225
+
226
+ handle_call({click, TabId, Selector}, _From, State) ->
227
+ Expr = click_js(Selector),
228
+ with_tab_sync(TabId, State, fun(Tab) ->
229
+ eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
230
+ #{<<"expression">> => Expr, <<"returnByValue">> => true}))
231
+ end, fun(_Result) ->
232
+ emit(State, <<"browser.action.click">>, #{tab_id => TabId, selector => Selector})
233
+ end);
234
+
235
+ handle_call({type, TabId, Selector, Text}, _From, State) ->
236
+ Expr = type_js(Selector, Text),
237
+ with_tab_sync(TabId, State, fun(Tab) ->
238
+ eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
239
+ #{<<"expression">> => Expr, <<"returnByValue">> => true}))
240
+ end, fun(_Result) ->
241
+ emit(State, <<"browser.action.type">>, #{tab_id => TabId, selector => Selector,
242
+ chars => byte_size(Text)})
243
+ end);
244
+
245
+ handle_call({scroll, TabId, DeltaY}, _From, State) ->
246
+ Expr = list_to_binary(io_lib:format("window.scrollBy(0, ~w); 'ok'", [DeltaY])),
247
+ with_tab_sync(TabId, State, fun(Tab) ->
248
+ eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
249
+ #{<<"expression">> => Expr, <<"returnByValue">> => true}))
250
+ end, fun(_Result) ->
251
+ emit(State, <<"browser.action.scroll">>, #{tab_id => TabId, delta_y => DeltaY})
252
+ end);
253
+
254
+ handle_call({screenshot, TabId}, _From, State) ->
255
+ with_tab_sync(TabId, State, fun(Tab) ->
256
+ case hk_cdp_client:command(Tab#tab.cdp_pid, <<"Page.captureScreenshot">>,
257
+ #{<<"format">> => <<"png">>}) of
258
+ {ok, #{<<"data">> := B64}} -> {ok, B64};
259
+ {error, Reason} -> {error, Reason}
260
+ end
261
+ end, fun(_Result) ->
262
+ emit(State, <<"browser.screenshot.captured">>, #{tab_id => TabId})
263
+ end);
264
+
265
+ handle_call({extract_links, TabId}, _From, State) ->
266
+ Expr = <<"JSON.stringify(Array.from(document.querySelectorAll('a[href]'))"
267
+ ".map(a => ({href: a.href, text: a.textContent.trim()})))">>,
268
+ with_tab_sync(TabId, State, fun(Tab) ->
269
+ case eval_result(hk_cdp_client:command(Tab#tab.cdp_pid, <<"Runtime.evaluate">>,
270
+ #{<<"expression">> => Expr, <<"returnByValue">> => true})) of
271
+ {ok, Json} when is_binary(Json) ->
272
+ try {ok, jsx:decode(Json, [return_maps])}
273
+ catch _:_ -> {ok, []} end;
274
+ Other -> Other
275
+ end
276
+ end, fun(Result) ->
277
+ Count = case Result of L when is_list(L) -> length(L); _ -> 0 end,
278
+ emit(State, <<"browser.links.extracted">>, #{tab_id => TabId, count => Count})
279
+ end);
280
+
281
+ handle_call(close_session, _From, State) ->
282
+ State2 = State#state{status = closing},
283
+ maps:foreach(fun(_TabId, Tab) -> hk_cdp_client:close(Tab#tab.cdp_pid) end, State2#state.tabs),
284
+ catch erlang:port_close(State2#state.os_port),
285
+ emit(State2, <<"browser.session.closed">>, #{}),
286
+ {stop, normal, ok, State2#state{status = closed}}.
287
+
288
+ handle_cast(_Msg, State) ->
289
+ {noreply, State}.
290
+
291
+ %% CDP event fan-in: correlate `Page.loadEventFired' back to whichever
292
+ %% navigate/back/forward/reload call is waiting on this tab.
293
+ handle_info({cdp_event, CdpPid, <<"Page.loadEventFired">>, _Params}, State) ->
294
+ case maps:find(CdpPid, State#state.cdp_pid_to_tab) of
295
+ {ok, TabId} ->
296
+ Key = {TabId, load_event},
297
+ case maps:take(Key, State#state.waiters) of
298
+ {From, Waiters2} ->
299
+ emit(State, <<"browser.navigation.completed">>, #{tab_id => TabId}),
300
+ gen_server:reply(From, ok),
301
+ {noreply, State#state{waiters = Waiters2}};
302
+ error ->
303
+ {noreply, State}
304
+ end;
305
+ error ->
306
+ {noreply, State}
307
+ end;
308
+ handle_info({cdp_event, _CdpPid, _Method, _Params}, State) ->
309
+ {noreply, State}; %% other CDP events are available for future views (console, network, ...)
310
+ handle_info({Port, {exit_status, Status}}, State = #state{os_port = Port, status = St})
311
+ when St =/= closing ->
312
+ emit(State, <<"browser.session.crashed">>, #{exit_status => Status}),
313
+ {stop, {error, {chrome_process_exited, Status}}, State#state{status = crashed}};
314
+ handle_info({Port, {exit_status, _Status}}, State = #state{os_port = Port}) ->
315
+ {noreply, State};
316
+ handle_info({Port, {data, _}}, State = #state{os_port = Port}) ->
317
+ {noreply, State};
318
+ handle_info(_Info, State) ->
319
+ {noreply, State}.
320
+
321
+ terminate(_Reason, _State) ->
322
+ ok.
323
+
324
+ %%% internal
325
+
326
+ find_free_port() ->
327
+ {ok, Socket} = gen_tcp:listen(0, [{reuseaddr, true}]),
328
+ {ok, Port} = inet:port(Socket),
329
+ ok = gen_tcp:close(Socket),
330
+ Port.
331
+
332
+ with_tab(TabId, State, Fun) ->
333
+ case maps:find(TabId, State#state.tabs) of
334
+ {ok, Tab} -> Fun(Tab);
335
+ error -> {reply, {error, no_such_tab}, State}
336
+ end.
337
+
338
+ %% For operations that complete synchronously against the CDP client
339
+ %% (i.e. not waiting on a later async event like page load): run
340
+ %% `EvalFun(Tab) -> {ok, Result} | {error, Reason}', emit `OnOk(Result)'
341
+ %% only on success, and always reply from this call.
342
+ with_tab_sync(TabId, State, EvalFun, OnOk) ->
343
+ case maps:find(TabId, State#state.tabs) of
344
+ {ok, Tab} ->
345
+ case EvalFun(Tab) of
346
+ {ok, Result} ->
347
+ OnOk(Result),
348
+ {reply, {ok, Result}, State};
349
+ {error, Reason} ->
350
+ emit(State, <<"browser.action.failed">>,
351
+ #{tab_id => TabId, reason => term_bin(Reason)}),
352
+ {reply, {error, Reason}, State}
353
+ end;
354
+ error ->
355
+ {reply, {error, no_such_tab}, State}
356
+ end.
357
+
358
+ eval_result({ok, #{<<"exceptionDetails">> := Details}}) ->
359
+ {error, {js_exception, Details}};
360
+ eval_result({ok, #{<<"result">> := #{<<"value">> := Value}}}) ->
361
+ {ok, Value};
362
+ eval_result({ok, #{<<"result">> := _}}) ->
363
+ {ok, undefined};
364
+ eval_result({error, Reason}) ->
365
+ {error, Reason}.
366
+
367
+ query_element_js(Selector) ->
368
+ SelJson = jsx:encode(Selector),
369
+ <<"(() => { const el = document.querySelector(", SelJson/binary, "); "
370
+ "return el ? JSON.stringify({exists:true, tag: el.tagName, "
371
+ "text:(el.textContent||'').trim().slice(0,500)}) : "
372
+ "JSON.stringify({exists:false}); })()">>.
373
+
374
+ click_js(Selector) ->
375
+ SelJson = jsx:encode(Selector),
376
+ <<"(() => { const el = document.querySelector(", SelJson/binary, "); "
377
+ "if (!el) return 'not_found'; el.click(); return 'ok'; })()">>.
378
+
379
+ type_js(Selector, Text) ->
380
+ SelJson = jsx:encode(Selector),
381
+ TextJson = jsx:encode(Text),
382
+ <<"(() => { const el = document.querySelector(", SelJson/binary, "); "
383
+ "if (!el) return 'not_found'; el.focus(); el.value = ", TextJson/binary, "; "
384
+ "el.dispatchEvent(new Event('input', {bubbles:true})); "
385
+ "el.dispatchEvent(new Event('change', {bubbles:true})); return 'ok'; })()">>.
386
+
387
+ put_tab(Tab, State) ->
388
+ State#state{tabs = maps:put(Tab#tab.tab_id, Tab, State#state.tabs)}.
389
+
390
+ drop_tab(TabId, Tab, State) ->
391
+ State#state{
392
+ tabs = maps:remove(TabId, State#state.tabs),
393
+ cdp_pid_to_tab = maps:remove(Tab#tab.cdp_pid, State#state.cdp_pid_to_tab)
394
+ }.
395
+
396
+ session_map(State) ->
397
+ #browser_session{
398
+ session_id = State#state.session_id,
399
+ owner_agent_id = State#state.owner_agent_id,
400
+ profile = State#state.profile,
401
+ status = State#state.status,
402
+ tabs = maps:keys(State#state.tabs),
403
+ created_at_ms = State#state.created_at_ms,
404
+ updated_at_ms = hk_id:timestamp_ms()
405
+ }.
406
+
407
+ emit(State, Category, Data) ->
408
+ Event = hk_event:new(Category, {browser_session, State#state.session_id}, Data),
409
+ catch hk_event_bus:publish(Event).
410
+
411
+ term_bin(Term) ->
412
+ iolist_to_binary(io_lib:format("~p", [Term])).
413
+
414
+ text_len(B) when is_binary(B) -> byte_size(B);
415
+ text_len(_) -> 0.