diff --git a/README.md b/README.md index b50ba9aa..fd179133 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,69 @@ config :vintage_net_wizard, captive_portal: false It is possible to write a smartphone app to configure your device using an API endpoint. Documentation for the API is in [json-api.md](json-api.md). +## Using as a Plug + +VintageNetWizard uses `Plug.Router` under the hood and is dynamically routed +which means you can plug it into an existing web app without running a separate +server and port. This will only run routing and delivery of assets and will not +put device into AP mode. + +**Using Plug.Router** +```elixir +forward "/wizard", to: VintageNetWizard.Web.Router +``` + +**Using Phoenix** +```elixir +scope "/", VintageNetWizard do + forward "/wizard", Web.Router +end +``` + +```elixir +scope "/", VintageNetWizard do + forward "/wizard", Web.Router +end +``` + +You can also specify an `:on_complete` option to specify an action to perform +when completing WiFi setup. Accepted values are: + +* a string path - This specifies the location to redirect to after completing +setup and is useful if you want it to route to a different page within your app +* `{module, function, args}` - tuple that defines a function to be run when +configuration is completed. + +```elixir +# Plug.Router +forward "/wizard", + to: VintageNetWizard.Web.Router, + init_opts: [on_complete: {MyModule, :handle_complete, []}] +``` + +```elixir +# Phoenix.Router +scope "/", VintageNetWizard do + forward "/wizard", Web.Router, on_complete: "/my_app/index" +end +``` + +If you wish to supply your own frontend UI, you can also use the JSON +endpoints in `VintageNetWizard.Web.Api` to fetch the data as needed from +the scope of your app: + +```elixir +# Plug.Router +forward "/wizard/api/v1", to: VintageNetWizard.Web.Api +``` + +```elixir +# Phoenix.Router +scope "/", VintageNetWizard do + forward "/wizard/api/v1", Web.Api +end +``` + ## Running the example The example builds a Nerves firmware image for supported Nerves devices @@ -209,7 +272,9 @@ config :vintage_net_wizard, ssid: "MY_SSID" ``` -## Stop callback +## Callbacks + +**On Exit** If your application runs a webserver or has other functionality that is incompatible with the wizard, you can use the `:on_exit` option to @@ -230,6 +295,26 @@ defmodule MyApp do end ``` +**On Complete** + +You can also specify a callback to be run when the configuration backend has completed, +but before the server exits with the `:on_complete` option which functions the same way +as `:on_exit` requiring `{module, function, args}` format. + +```elixir +defmodule MyApp do + def start_wizard() do + VintageNetWizard.run_wizard( + on_complete: {__MODULE__, :handle_on_complete, []} + ) + end + + def handle_on_complete() do + Logger.info("VintageNetWizard stopped") + end +end +``` + ## Development It's possible to work on the wizard locally and without using Nerves or changing diff --git a/lib/vintage_net_wizard.ex b/lib/vintage_net_wizard.ex index 010ce086..90fe75ff 100644 --- a/lib/vintage_net_wizard.ex +++ b/lib/vintage_net_wizard.ex @@ -16,6 +16,9 @@ defmodule VintageNetWizard do - `:ssl` - A Keyword list of `:ssl.tls_server_options` - `:on_exit` - `{module, function, args}` tuple specifying callback to perform after stopping the server. + - `:on_complete` - `{module, function, args}` tuple specifying + callback to run after completing configuration, but before + server is shutdown. Expected to be an mfa See `Plug.SSL.configure/1` for more information about the SSL options. diff --git a/lib/vintage_net_wizard/application.ex b/lib/vintage_net_wizard/application.ex index 602c8dc5..db5c82fc 100644 --- a/lib/vintage_net_wizard/application.ex +++ b/lib/vintage_net_wizard/application.ex @@ -3,11 +3,17 @@ defmodule VintageNetWizard.Application do use Application + alias VintageNetWizard.{Backend, BackendServer, Callbacks} + @spec start(Application.start_type(), any()) :: {:error, any} | {:ok, pid()} def start(_type, _args) do + backend = Application.get_env(:vintage_net_wizard, :backend, Backend.Default) + children = [ {Task.Supervisor, name: VintageNetWizard.TaskSupervisor}, - VintageNetWizard.Web.Endpoint + VintageNetWizard.Web.Endpoint, + {BackendServer, backend}, + Callbacks ] opts = [strategy: :one_for_one, name: VintageNetWizard.Supervisor] diff --git a/lib/vintage_net_wizard/backend_server.ex b/lib/vintage_net_wizard/backend_server.ex index 9eceb14c..b2335244 100644 --- a/lib/vintage_net_wizard/backend_server.ex +++ b/lib/vintage_net_wizard/backend_server.ex @@ -7,6 +7,9 @@ defmodule VintageNetWizard.BackendServer do alias VintageNetWizard.WiFiConfiguration alias VintageNetWiFi.AccessPoint + # Hibernate after 5 minutes + @hibernate_after 5 * 60 * 1000 + defmodule State do @moduledoc false defstruct subscriber: nil, backend: nil, backend_state: nil, configurations: [] @@ -14,7 +17,7 @@ defmodule VintageNetWizard.BackendServer do @spec start_link(backend :: module()) :: GenServer.on_start() def start_link(backend) do - GenServer.start_link(__MODULE__, backend, name: __MODULE__) + GenServer.start_link(__MODULE__, backend, name: __MODULE__, hibernate_after: @hibernate_after) end @doc """ diff --git a/lib/vintage_net_wizard/callbacks.ex b/lib/vintage_net_wizard/callbacks.ex index c9919381..65cacfb8 100644 --- a/lib/vintage_net_wizard/callbacks.ex +++ b/lib/vintage_net_wizard/callbacks.ex @@ -3,8 +3,11 @@ defmodule VintageNetWizard.Callbacks do require Logger - def start_link(callbacks) do - callbacks = Enum.reduce(callbacks, [], &validate_callback/2) + def start_link(callbacks \\ []) do + callbacks = + starting_callbacks(callbacks) + |> Enum.reduce([], &validate_callback/2) + Agent.start_link(fn -> callbacks end, name: __MODULE__) end @@ -12,12 +15,23 @@ defmodule VintageNetWizard.Callbacks do Agent.get(__MODULE__, & &1) end + def on_complete() do + list() + |> Keyword.get(:on_complete) + |> apply_callback() + end + def on_exit() do list() |> Keyword.get(:on_exit) |> apply_callback() end + def set_callbacks(callbacks) do + cbs = Enum.reduce(callbacks, [], &validate_callback/2) + Agent.update(__MODULE__, &Keyword.merge(&1, cbs)) + end + defp apply_callback({mod, fun, args}) do try do apply(mod, fun, args) @@ -29,6 +43,17 @@ defmodule VintageNetWizard.Callbacks do defp apply_callback(invalid), do: {:error, "invalid callback: #{inspect(invalid)}"} + defp starting_callbacks(initial) do + cbs = Application.get_env(:vintage_net_wizard, :callbacks, []) + + if Keyword.keyword?(cbs) do + Keyword.merge(cbs, initial) + else + Logger.warn("[VintageNetWizard] invalid callbacks defined in config:\n\t#{inspect(cbs)}\n") + initial + end + end + defp validate_callback({_key, {mod, fun, args}} = callback, acc) when is_atom(mod) and is_atom(fun) and is_list(args) do [callback | acc] diff --git a/lib/vintage_net_wizard/web/api.ex b/lib/vintage_net_wizard/web/api.ex index bf7b14b5..fa43b72a 100644 --- a/lib/vintage_net_wizard/web/api.ex +++ b/lib/vintage_net_wizard/web/api.ex @@ -1,15 +1,14 @@ defmodule VintageNetWizard.Web.Api do @moduledoc false - alias VintageNetWizard.{WiFiConfiguration, BackendServer} - alias VintageNetWizard.Web.Endpoint + alias VintageNetWizard.{Callbacks, WiFiConfiguration, BackendServer} alias Plug.Conn use Plug.Router plug(Plug.Parsers, parsers: [:json], json_decoder: Jason) plug(:match) - plug(:dispatch) + plug(:dispatch, builder_opts()) get "/configuration/status" do with status <- BackendServer.configuration_status(), @@ -37,13 +36,12 @@ defmodule VintageNetWizard.Web.Api do get "/complete" do :ok = BackendServer.complete() - _ = - Task.Supervisor.start_child(VintageNetWizard.TaskSupervisor, fn -> - # We don't want to stop the server before we - # send the response back. - :timer.sleep(3000) - Endpoint.stop_server() - end) + # Set the callback if provided as a Plug opt + if callback = opts[:on_complete] do + Callbacks.set_callbacks(on_complete: callback) + end + + _ = Callbacks.on_complete() send_json(conn, 202, "") end diff --git a/lib/vintage_net_wizard/web/endpoint.ex b/lib/vintage_net_wizard/web/endpoint.ex index b26e7ac0..68f66869 100644 --- a/lib/vintage_net_wizard/web/endpoint.ex +++ b/lib/vintage_net_wizard/web/endpoint.ex @@ -2,7 +2,8 @@ defmodule VintageNetWizard.Web.Endpoint do @moduledoc """ Supervisor for the Web part of the VintageNet Wizard. """ - alias VintageNetWizard.{BackendServer, Callbacks, Web.Router, Web.RedirectRouter} + alias VintageNetWizard.{Callbacks, Web.Router, Web.RedirectRouter} + alias VintageNetWizard.TaskSupervisor, as: Tasks use DynamicSupervisor @type opt :: {:ssl, :ssl.tls_server_option()} | {:on_exit, {module(), atom(), list()}} @@ -23,15 +24,15 @@ defmodule VintageNetWizard.Web.Endpoint do :ok | {:error, :already_started | :no_keyfile | :no_certfile} def start_server(opts \\ []) do use_ssl? = Keyword.has_key?(opts, :ssl) - use_captive_portal? = Application.get_env(:vintage_net_wizard, :captive_portal, true) - backend = Application.get_env(:vintage_net_wizard, :backend, VintageNetWizard.Backend.Default) - callbacks = Keyword.take(opts, [:on_exit]) + + use_captive_portal? = + opts[:captive_portal] || Application.get_env(:vintage_net_wizard, :captive_portal, true) + + _ = set_callbacks(opts) with spec <- maybe_use_ssl(use_ssl?, opts), {:ok, _pid} <- DynamicSupervisor.start_child(__MODULE__, spec), - {:ok, _pid} <- maybe_with_redirect(use_captive_portal?, use_ssl?), - {:ok, _pid} <- DynamicSupervisor.start_child(__MODULE__, {BackendServer, backend}), - {:ok, _pid} <- DynamicSupervisor.start_child(__MODULE__, {Callbacks, callbacks}) do + {:ok, _pid} <- maybe_with_redirect(use_captive_portal?, use_ssl?) do :ok else {:error, :max_children} -> {:error, :already_started} @@ -49,26 +50,17 @@ defmodule VintageNetWizard.Web.Endpoint do {:error, :not_found} children -> - # Ensure we terminate callbacks last after all other children - # and the callbacks have been executed - callbacks_child = - Enum.reduce(children, nil, fn {_, child, _, [mod]}, _acc -> - if mod == Callbacks do - child - else - DynamicSupervisor.terminate_child(__MODULE__, child) - end - end) + Enum.each(children, fn {_, child, _, _} -> + DynamicSupervisor.terminate_child(__MODULE__, child) + end) _ = Callbacks.on_exit() - - _ = DynamicSupervisor.terminate_child(__MODULE__, callbacks_child) end end @impl DynamicSupervisor def init(_) do - DynamicSupervisor.init(strategy: :one_for_one, max_children: 4) + DynamicSupervisor.init(strategy: :one_for_one, max_children: 2) end defp dispatch do @@ -131,4 +123,22 @@ defmodule VintageNetWizard.Web.Endpoint do ] ) end + + defp set_callbacks(opts) do + on_complete = + {Task.Supervisor, :start_child, + [ + Tasks, + fn -> + # We don't want to stop the server before we + # send the response back. + :timer.sleep(3000) + __MODULE__.stop_server() + end + ]} + + Keyword.take(opts, [:on_exit]) + |> Keyword.put(:on_complete, on_complete) + |> Callbacks.set_callbacks() + end end diff --git a/lib/vintage_net_wizard/web/router.ex b/lib/vintage_net_wizard/web/router.ex index e66316a8..8161ac39 100644 --- a/lib/vintage_net_wizard/web/router.ex +++ b/lib/vintage_net_wizard/web/router.ex @@ -6,7 +6,7 @@ defmodule VintageNetWizard.Web.Router do alias VintageNetWizard.{ BackendServer, - Web.Endpoint, + Callbacks, WiFiConfiguration } @@ -14,12 +14,12 @@ defmodule VintageNetWizard.Web.Router do plug(Plug.Static, from: {:vintage_net_wizard, "priv/static"}, at: "/") plug(Plug.Parsers, parsers: [Plug.Parsers.URLENCODED, :json], json_decoder: Jason) plug(:match) - plug(:dispatch) + plug(:dispatch, builder_opts()) get "/" do case BackendServer.configurations() do [] -> - redirect(conn, "/networks") + redirect(conn, relative_path(conn) <> "networks") configs -> render_page(conn, "index.html", @@ -38,7 +38,8 @@ defmodule VintageNetWizard.Web.Router do case WiFiConfiguration.from_params(params) do {:ok, wifi_config} -> :ok = BackendServer.save(wifi_config) - redirect(conn, "/") + + redirect(conn, relative_path(conn)) error -> {:ok, key_mgmt} = WiFiConfiguration.key_mgmt_from_string(conn.body_params["key_mgmt"]) @@ -102,7 +103,7 @@ defmodule VintageNetWizard.Web.Router do {:ok, config} = WiFiConfiguration.from_params(conn.body_params) :ok = BackendServer.save(config) - redirect(conn, "/") + redirect(conn, relative_path(conn)) key_mgmt -> key_mgmt = String.to_existing_atom(key_mgmt) @@ -117,15 +118,7 @@ defmodule VintageNetWizard.Web.Router do get "/complete" do :ok = BackendServer.complete() - _ = - Task.Supervisor.start_child(VintageNetWizard.TaskSupervisor, fn -> - # We don't want to stop the server before we - # send the response back. - :timer.sleep(3000) - Endpoint.stop_server() - end) - - render_page(conn, "complete.html") + do_on_complete(conn, opts[:on_complete]) end forward("/api/v1", to: VintageNetWizard.Web.Api) @@ -155,7 +148,7 @@ defmodule VintageNetWizard.Web.Router do end defp render_page(conn, page, info \\ []) do - info = [device_info: BackendServer.device_info()] ++ info + info = [relative_path: relative_path(conn), device_info: BackendServer.device_info()] ++ info resp = page @@ -233,4 +226,32 @@ defmodule VintageNetWizard.Web.Router do %{value: status, class: "text-warning", title: "Device waiting to be configured."} end end + + defp relative_path(conn) do + case conn.script_name do + [] -> "/" + path -> "/#{Path.join(path)}/" + end + end + + defp do_on_complete(conn, opt \\ nil) + + defp do_on_complete(conn, path) when is_binary(path) do + # A path was provided to signify redirect location + # Run callback (if any), then redirect + _ = Callbacks.on_complete() + redirect(conn, path) + end + + defp do_on_complete(conn, {_, _, _} = mfa) do + # Set the callback first if provided + Callbacks.set_callbacks(on_complete: mfa) + + do_on_complete(conn) + end + + defp do_on_complete(conn, _) do + _ = Callbacks.on_complete() + render_page(conn, "complete.html") + end end diff --git a/priv/static/js/access_point.js b/priv/static/js/access_point.js index bd36fab1..d9125b12 100644 --- a/priv/static/js/access_point.js +++ b/priv/static/js/access_point.js @@ -3,11 +3,13 @@ (() => { const accessPointsTable = document.querySelector(".access-points-table"); const body = accessPointsTable.tBodies.item(0); + const relative_path = document.getElementById("relative-path").value; + getAccessPoints(); setInterval(getAccessPoints, 5000); function getAccessPoints() { - fetch("/api/v1/access_points") + fetch(relative_path + "api/v1/access_points") .then((resp) => resp.json()) .then((accessPoints) => { body.innerHTML = ""; @@ -92,7 +94,7 @@ networkTR.addEventListener("click", ({ target }) => { const ssid = target.parentElement.dataset.ssid if (type === "addConfig") { - fetch(`/api/v1/${ssid}/configuration`, { + fetch(`${relative_path}api/v1/${ssid}/configuration`, { method: "PUT", headers: { "Content-Type": "application/json" @@ -103,10 +105,10 @@ }) }) .then(resp => { - window.location.href = "/"; + window.location.href = relative_path; }) } else { - window.location.href = "/ssid/" + encodeURI(ssid); + window.location.href = relative_path + "ssid/" + encodeURI(ssid); } }); } diff --git a/priv/static/js/app.js b/priv/static/js/app.js index b177dcbe..ad53341b 100644 --- a/priv/static/js/app.js +++ b/priv/static/js/app.js @@ -10,6 +10,8 @@ const signal_to_class = function (signal) { } } +const relative_path = document.getElementById("relative-path").value; + /** * Adds or updates an element on one of the ssid list tables. * data should contain the following keys: @@ -126,7 +128,7 @@ const handle_scanned_ssid = function (data, table_id) { const parseResponse = (response) => response.json(); const getAccessPoints = () => { - return fetch("/api/v1/access_points") + return fetch(relative_path + "api/v1/access_points") .then(parseResponse) .then((json) => { handle_scanned_ssid(json, "wifi_scan"); @@ -134,7 +136,7 @@ const getAccessPoints = () => { } const save = () => { - fetch("/api/v1/apply", { + fetch(relative_path + "api/v1/apply", { method: "POST", headers: { "Content-Type": "application/json" @@ -157,7 +159,7 @@ const addSsid = function () { config[elem.name] = elem.value; } - fetch("/api/v1/configurations", { + fetch(relative_path + "api/v1/configurations", { method: "PUT", headers: { "Content-Type": "application/json" diff --git a/priv/static/js/apply.js b/priv/static/js/apply.js index b38e2fbd..2d981ffe 100644 --- a/priv/static/js/apply.js +++ b/priv/static/js/apply.js @@ -11,12 +11,14 @@ ssid: document.getElementById("ssid").getAttribute("value") } + const relative_path = document.getElementById("relative-path").value; + function runGetStatus() { setTimeout(getStatus, 1000); } function getStatus() { - fetch("/api/v1/configuration/status") + fetch(relative_path + "api/v1/configuration/status") .then(resp => resp.json()) .then(handleStatusResponse) .catch(handleNetworkErrorResponse); @@ -50,7 +52,7 @@ } function createCompleteLink({ targetElem, view }) { - const button = document.createElement("button"); + const button = document.createElement("a"); var btnClass = "btn-primary"; var btnText = "Complete"; @@ -61,20 +63,12 @@ button.classList.add("btn"); button.classList.add(btnClass); - button.addEventListener("click", handleCompleteClick); + button.setAttribute("href", relative_path + "complete"); button.innerHTML = btnText; targetElem.appendChild(button); } - function handleCompleteClick(e) { - if (state.completeTimer) { - clearTimeout(state.completeTimer); - state.completeTimer = null; - } - complete(); - } - function view({view, dots, ssid}) { switch (view) { case "trying": @@ -106,21 +100,13 @@

Check your setup and try configuring again. Or you can also skip verification to save the configuration as is.

- Configure + Configure `, createCompleteLink]; case "complete": return ["Configuration complete", null]; } } - function complete() { - fetch("/api/v1/complete") - .then(resp => { - state.view = "complete"; - render(state); - }); - } - function render(state) { const [innerHTML, action] = view(state); state.targetElem.innerHTML = innerHTML; @@ -130,7 +116,7 @@ } } - fetch("/api/v1/apply", { + fetch(relative_path + "api/v1/apply", { method: "POST", headers: { "Content-Type": "application/json" diff --git a/priv/static/js/delete_configuration.js b/priv/static/js/delete_configuration.js index d29a14cb..264410ac 100644 --- a/priv/static/js/delete_configuration.js +++ b/priv/static/js/delete_configuration.js @@ -2,12 +2,13 @@ const configurations = () => { const deleteConfigs = document.querySelectorAll(".configuration-delete"); + const relative_path = document.getElementById("relative-path").value; for (let i = 0; i < deleteConfigs.length; i++) { deleteConfigs[i].addEventListener("click", (e) => { const td = e.currentTarget.parentElement; const ssid = td.dataset.ssid; - fetch(`/api/v1/${ssid}/configuration`, { + fetch(`${relative_path}api/v1/${ssid}/configuration`, { method: "DELETE", headers: { "Content-Type": "application/json" diff --git a/priv/templates/apply.html.eex b/priv/templates/apply.html.eex index 52e46c01..01ed268e 100644 --- a/priv/templates/apply.html.eex +++ b/priv/templates/apply.html.eex @@ -3,12 +3,13 @@ VintageNet Wizard - - + + +
-

VintageNet Wizard

+

VintageNet Wizard

@@ -30,6 +31,6 @@
<% end %> - + diff --git a/priv/templates/complete.html.eex b/priv/templates/complete.html.eex index e8e6b4d4..a16b91d7 100644 --- a/priv/templates/complete.html.eex +++ b/priv/templates/complete.html.eex @@ -3,12 +3,13 @@ VintageNet Wizard - - + + +
-

VintageNet Wizard

+

VintageNet Wizard

WiFi configuration is now complete! @@ -23,6 +24,6 @@
<% end %> - + diff --git a/priv/templates/configure_enterprise.html.eex b/priv/templates/configure_enterprise.html.eex index 9f78db04..817ab989 100644 --- a/priv/templates/configure_enterprise.html.eex +++ b/priv/templates/configure_enterprise.html.eex @@ -3,17 +3,18 @@ VintageNet Wizard - - + + +
-

VintageNet Wizard

+

VintageNet Wizard

The Wi-Fi network "<%= ssid %>" requires a WPA2 password. -
+
diff --git a/priv/templates/configure_password.html.eex b/priv/templates/configure_password.html.eex index 88b21403..54a6bfc1 100644 --- a/priv/templates/configure_password.html.eex +++ b/priv/templates/configure_password.html.eex @@ -3,17 +3,18 @@ VintageNet Wizard - - + + +
-

VintageNet Wizard

+

VintageNet Wizard

The Wi-Fi network "<%= ssid %>" requires a WPA2 password. - +
diff --git a/priv/templates/index.html.eex b/priv/templates/index.html.eex index c123b7b6..5b4cd1b8 100644 --- a/priv/templates/index.html.eex +++ b/priv/templates/index.html.eex @@ -3,12 +3,13 @@ VintageNet Wizard - - + + +
-

VintageNet Wizard

+

VintageNet Wizard

Configuration Status: @@ -40,7 +41,7 @@ <%= if get_key_mgmt.(config) == :none do %> <% else %> - + @@ -59,10 +60,10 @@

- Add a New Network + Add a New Network <%= if length(configs) > 0 do %> - Apply configuration - Complete Without Verification + Apply configuration + Complete Without Verification <% end %>
@@ -76,8 +77,8 @@ <% end %> - - + + +