%% @author Marc Worrell %% @copyright 2009-2020 Marc Worrell %% @doc Support functions for development. %% Copyright 2009-2020 Marc Worrell %% %% Licensed under the Apache License, Version 2.0 (the "License"); %% you may not use this file except in compliance with the License. %% You may obtain a copy of the License at %% %% http://www.apache.org/licenses/LICENSE-2.0 %% %% Unless required by applicable law or agreed to in writing, software %% distributed under the License is distributed on an "AS IS" BASIS, %% WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. %% See the License for the specific language governing permissions and %% limitations under the License. -module(mod_development). -author("Marc Worrell "). -behaviour(gen_server). -mod_title("Development"). -mod_description("Development support, periodically builds and loads changed files."). -mod_prio(1000). %% gen_server exports -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). -export([start_link/1]). %% interface functions -export([ reload/1, make/1, debug_stream/3, observe_debug_stream/2, observe_filewatcher/2, pid_observe_development_reload/3, pid_observe_development_make/3, observe_admin_menu/3, observe_request_context/3, chrome/1, chrome/2, chrome/3, chromium/1, chromium/2, chromium/3, exec_browser/4, exec_browser/5, % internal (for spawn) page_debug_stream/3, page_debug_stream_loop/3 ]). -include_lib("zotonic_core/include/zotonic.hrl"). -include_lib("zotonic_mod_admin/include/admin_menu.hrl"). -record(state, { site :: atom() | undefined }). % Interval for checking for new and/or changed files. -define(DEV_POLL_INTERVAL, 10000). %%==================================================================== %% API %%==================================================================== %% @spec start_link(Args) -> {ok,Pid} | ignore | {error,Error} %% @doc Starts the server start_link(Args) when is_list(Args) -> gen_server:start_link(?MODULE, Args, []). reload(Context) -> z_notifier:notify(development_reload, Context). make(Context) -> z_notifier:notify(development_make, Context). %% @doc Stream specific debug information to an area on the current page. debug_stream(TargetId, What, Context) -> z_notifier:notify1(#debug_stream{target=TargetId, what=What}, Context). %% @doc Stream all debug information of a certain kind to the target id on the user agent. observe_debug_stream(#debug_stream{target=TargetId, what=What}, Context) -> start_debug_stream(TargetId, What, Context). %% @doc Catch filewatcher file change events, reloads css or the open pages. observe_filewatcher(#filewatcher{ file = File, extension = Extension }, Context) -> case z_convert:to_bool(m_config:get_value(mod_development, livereload, Context)) of true -> maybe_livereload(Extension, File, Context); false -> ok end. pid_observe_development_reload(Pid, development_reload, _Context) -> gen_server:cast(Pid, development_reload). pid_observe_development_make(Pid, development_make, _Context) -> gen_server:cast(Pid, development_make). observe_request_context(#request_context{ phase = refresh }, Context, _Context) -> z_development_dbtrace:copy_from_session(Context), Context; observe_request_context(#request_context{ phase = _ }, Context, _Context) -> Context. %% @doc Runs Chrome opening it in the site URL. %% Ignore certificate errors and defines the site as secure, helpful to run Web Workers. %% For extra args @see https://peter.sh/experiments/chromium-command-line-switches/ %% Common args: %% --incognito Launches Chrome directly in Incognito private browsing mode %% --purge-memory-button Add purge memory button to Chrome %% --multi-profiles Enable multiple profiles in Chrome %% e.g. %% ``` mod_development:chrome(foo, ["--incognito", "--start-maximized"]). ''' chrome(SiteOrContext) -> chrome(SiteOrContext, []). chrome(SiteOrContext, ExtraArgs) -> chrome(SiteOrContext, ExtraArgs, #{}). chrome(SiteOrContext, ExtraArgs, Options) -> exec_browser(chrome, SiteOrContext, ExtraArgs, Options). %% @doc Runs Chromium opening it in the site URL. %% Ignore certificate errors and defines the site as secure, helpful to run Web Workers. %% For extra args @see https://peter.sh/experiments/chromium-command-line-switches/ %% Common args: %% --incognito Launches Chromium directly in Incognito private browsing mode %% --purge-memory-button Add purge memory button to Chromium %% --multi-profiles Enable multiple profiles in Chromium %% e.g. %% ``` mod_development:chromium(foo, ["--incognito", "--start-maximized"]). ''' chromium(SiteOrContext) -> chromium(SiteOrContext, []). chromium(SiteOrContext, ExtraArgs) -> chromium(SiteOrContext, ExtraArgs, #{}). chromium(SiteOrContext, ExtraArgs, Options) -> exec_browser(chromium, SiteOrContext, ExtraArgs, Options). %% @doc Opens the site URL as secure in a browser %% Currently supported: %% * Linux [Chrome, Chromium] %% * macOS [Chrome, Chromium] %% @todo: support more OS and maybe other browsers -spec exec_browser(Browser, SiteOrContext, ExtraArgs, Options) -> RetType when Browser :: atom(), SiteOrContext :: atom() | z:context(), ExtraArgs :: [string()], Options :: map(), RetType :: {ok, term()} | {error, term()}. exec_browser(Browser, #context{} = Context, ExtraArgs, Options) -> OS = os:type(), SiteUrl = z_context:abs_url(<<"/">>, Context), exec_browser(Browser, OS, SiteUrl, ExtraArgs, Options); exec_browser(Browser, Site, ExtraArgs, Options) -> exec_browser(Browser, z:c(Site), ExtraArgs, Options). exec_browser(chrome, {unix, linux}, SiteUrl, ExtraArgs, Options) -> case os:find_executable("google-chrome") of false -> {error, "Chrome executable not found."}; Executable -> exec_chrome(Executable, SiteUrl, ExtraArgs, Options) end; exec_browser(chrome, {unix, darwin}, SiteUrl, ExtraArgs, Options) -> Executable = "/Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome", exec_chrome(Executable, SiteUrl, ExtraArgs, Options); exec_browser(chromium, {unix, linux}, SiteUrl, ExtraArgs, Options) -> case os:find_executable("chromium-browser") of false -> {error, "Chromium executable not found."}; Executable -> exec_chrome(Executable, SiteUrl, ExtraArgs, Options) end; exec_browser(chromium, {unix, darwin}, SiteUrl, ExtraArgs, Options) -> Executable = "/Applications/Chromium.app/Contents/MacOS/Chromium", exec_chrome(Executable, SiteUrl, ExtraArgs, Options); exec_browser(_Browser, _OS, _SiteUrl, _ExtraArgs, _Options) -> {error, "Browser or operating system not supported."}. %%==================================================================== %% gen_server callbacks %%==================================================================== %% @spec init(Args) -> {ok, State} | %% {ok, State, Timeout} | %% ignore | %% {stop, Reason} %% @doc Initiates the server. init(Args) -> {context, Context} = proplists:lookup(context, Args), Site = z_context:site(Context), logger:set_process_metadata(#{ site => Site, module => ?MODULE }), {ok, #state{site=Site}}. %% @spec handle_call(Request, From, State) -> {reply, Reply, State} | %% {reply, Reply, State, Timeout} | %% {noreply, State} | %% {noreply, State, Timeout} | %% {stop, Reason, Reply, State} | %% {stop, Reason, State} %% @doc Trap unknown calls handle_call(Message, _From, State) -> {stop, {unknown_call, Message}, State}. %% @spec handle_cast(Msg, State) -> {noreply, State} | %% {noreply, State, Timeout} | %% {stop, Reason, State} handle_cast(development_reload, State) -> zotonic_filewatcher_beam_reloader:reload(), {noreply, State}; handle_cast(development_make, State) -> zotonic_filewatcher_beam_reloader:make(), {noreply, State}; %% @doc Trap unknown casts handle_cast(Message, State) -> {stop, {unknown_cast, Message}, State}. %% @spec handle_info(Info, State) -> {noreply, State} | %% {noreply, State, Timeout} | %% {stop, Reason, State} %% @doc Handling all non call/cast messages handle_info(_Info, State) -> {noreply, State}. %% @spec terminate(Reason, State) -> void() %% @doc This function is called by a gen_server when it is about to %% terminate. It should be the opposite of Module:init/1 and do any necessary %% cleaning up. When it returns, the gen_server terminates with Reason. %% The return value is ignored. terminate(_Reason, _State) -> ok. %% @spec code_change(OldVsn, State, Extra) -> {ok, NewState} %% @doc Convert process state when code is changed code_change(_OldVsn, State, _Extra) -> {ok, State}. %%==================================================================== %% support functions %%==================================================================== maybe_livereload(Ext, File, Context) when Ext =:= <<".css">>; Ext =:= <<".gif">>; Ext =:= <<".jpg">>; Ext =:= <<".png">>; Ext =:= <<".svg">> -> maybe_livereload_lib(File, Context); maybe_livereload(<<".js">>, File, Context) -> case binary:split(File, <<"/lib/">>) of [ _, _Path ] -> livereload_page(Context); [ _ ] -> ok end; maybe_livereload(<<".tpl">>, File, Context) -> case binary:split(File, <<"/templates/">>) of [ _, _Path ] -> livereload_page(Context); [ _ ] -> ok end; maybe_livereload(_Ext, _File, _Context) -> ok. maybe_livereload_lib(File, Context) -> case binary:split(File, <<"/lib/">>) of [ _, Path ] -> Url = z_dispatcher:url_for(lib, [ {star, Path} ], z_context:set_language(undefined, Context)), z_mqtt:publish( <<"public/development/livereload">>, [ {is_page_reload, false}, {path, Url} ], z_acl:sudo(Context)); [ _ ] -> ok end. livereload_page(Context) -> z_mqtt:publish( <<"public/development/livereload">>, [ {is_page_reload, true} ], z_acl:sudo(Context)). %% @doc Start a listener for a certain kind of debug information, echo it to the target id on the current page. %% @todo Check if we want this for Zotonic 1.0 start_debug_stream(_TargetId, _What, _Context) -> ?LOG_CRITICAL("Debug stream not functional yet"), % Context1 = z_context:prune_for_async(Context), % z_session_page:spawn_link(?MODULE, page_debug_stream, [TargetId, What, Context1], Context1). ok. %% @doc Process started and linked to the current page, subscribes to debug notifications page_debug_stream(TargetId, What, Context) -> process_flag(trap_exit, true), z_notifier:observe(debug, self(), Context), ?MODULE:page_debug_stream_loop(TargetId, What, Context). page_debug_stream_loop(TargetId, What, Context) -> receive {'EXIT', _} -> z_notifier:detach(debug, self(), Context), done; {'$gen_cast', {#debug{what=What, arg=Arg}, _Context}} -> %% Update the target id with a dump of this debug message _S = io_lib:format("~p: ~p~n", [What, Arg]), % z_session_page:add_script(z_render:insert_top(TargetId, S, Context)), ?MODULE:page_debug_stream_loop(TargetId, What, Context); {'$gen_cast', {#debug{what=_Other}, _Context}} -> ?MODULE:page_debug_stream_loop(TargetId, What, Context) end. observe_admin_menu(#admin_menu{}, Acc, Context) -> [ #menu_item{id=admin_development, parent=admin_system, label=?__("Development", Context), url={admin_development}, visiblecheck={acl, use, mod_development}} |Acc]. exec_chrome(Executable, SiteUrl, ExtraArgs, #{secure := true} = Options) -> TmpPath = filename:join([ z_tempfile:temppath(), "zotonic-chrome" ]), Args = lists:join(" ", [ "--user-data-dir=" ++ TmpPath, "--ignore-certificate-errors", "--unsafely-treat-insecure-origin-as-secure=" ++ SiteUrl | ExtraArgs ]), do_exec_chrome(Executable, SiteUrl, Args, Options); exec_chrome(Executable, SiteUrl, ExtraArgs, Options) -> do_exec_chrome(Executable, SiteUrl, ExtraArgs, Options). do_exec_chrome(Executable, SiteUrl, Args, _Options) -> Command = io_lib:format("~s ~s ~s", [ z_filelib:os_escape(Executable), Args, z_filelib:os_filename(SiteUrl) ]), io:format( "Trying to execute the commmand:\n$ ~s\n", [unicode:characters_to_list(Command)] ), do_exec_browser(Command). do_exec_browser(Command) -> case catch open_port({spawn, Command}, [in, hide]) of Port when is_port(Port) -> {ok, Port}; Error -> {error, Error} end.