27 | //
28 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
29 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
30 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
31 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
32 |
33 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle
34 | // See your `CoreComponents.icon/1` for more information.
35 | //
36 | plugin(function({matchComponents, theme}) {
37 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
38 | let values = {}
39 | let icons = [
40 | ["", "/24/outline"],
41 | ["-solid", "/24/solid"],
42 | ["-mini", "/20/solid"],
43 | ["-micro", "/16/solid"]
44 | ]
45 | icons.forEach(([suffix, dir]) => {
46 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
47 | let name = path.basename(file, ".svg") + suffix
48 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
49 | })
50 | })
51 | matchComponents({
52 | "hero": ({name, fullPath}) => {
53 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
54 | let size = theme("spacing.6")
55 | if (name.endsWith("-mini")) {
56 | size = theme("spacing.5")
57 | } else if (name.endsWith("-micro")) {
58 | size = theme("spacing.4")
59 | }
60 | return {
61 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
62 | "-webkit-mask": `var(--hero-${name})`,
63 | "mask": `var(--hero-${name})`,
64 | "mask-repeat": "no-repeat",
65 | "background-color": "currentColor",
66 | "vertical-align": "middle",
67 | "display": "inline-block",
68 | "width": size,
69 | "height": size
70 | }
71 | }
72 | }, {values})
73 | })
74 | ]
75 | }
76 |
--------------------------------------------------------------------------------
/nexus/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :nexus,
11 | generators: [timestamp_type: :utc_datetime]
12 |
13 | # Configures the endpoint
14 | config :nexus, NexusWeb.Endpoint,
15 | url: [host: "localhost"],
16 | adapter: Bandit.PhoenixAdapter,
17 | render_errors: [
18 | formats: [html: NexusWeb.ErrorHTML, json: NexusWeb.ErrorJSON],
19 | layout: false
20 | ],
21 | pubsub_server: Nexus.PubSub,
22 | live_view: [signing_salt: "/ONXsVON"]
23 |
24 | # Configure esbuild (the version is required)
25 | config :esbuild,
26 | version: "0.17.11",
27 | nexus: [
28 | args:
29 | ~w(js/app.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
30 | cd: Path.expand("../assets", __DIR__),
31 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
32 | ]
33 |
34 | # Configure tailwind (the version is required)
35 | config :tailwind,
36 | version: "3.4.0",
37 | nexus: [
38 | args: ~w(
39 | --config=tailwind.config.js
40 | --input=css/app.css
41 | --output=../priv/static/assets/app.css
42 | ),
43 | cd: Path.expand("../assets", __DIR__)
44 | ]
45 |
46 | # Configures Elixir's Logger
47 | config :logger, :console,
48 | format: "$time $metadata[$level] $message\n",
49 | metadata: [:request_id],
50 | level: :debug
51 |
52 | # Use Jason for JSON parsing in Phoenix
53 | config :phoenix, :json_library, Jason
54 |
55 | config :nexus,
56 | admin_username: "admin",
57 | admin_password: "admin"
58 |
59 | # Import environment specific config. This must remain at the bottom
60 | # of this file so it overrides the configuration defined above.
61 | import_config "#{config_env()}.exs"
62 |
--------------------------------------------------------------------------------
/nexus/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we can use it
8 | # to bundle .js and .css sources.
9 | config :nexus, NexusWeb.Endpoint,
10 | # Binding to loopback ipv4 address prevents access from other machines.
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {0, 0, 0, 0}, port: System.get_env("PORT", "4000") |> String.to_integer()],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "MC0EKvuSeGvebcMTYQh5nwte1ePB2u8xfuQCcv9FrPT2R4VA8Cyg9ADkI16v0uoR",
17 | watchers: [
18 | esbuild: {Esbuild, :install_and_run, [:nexus, ~w(--sourcemap=inline --watch)]},
19 | tailwind: {Tailwind, :install_and_run, [:nexus, ~w(--watch)]}
20 | ]
21 |
22 | # ## SSL Support
23 | #
24 | # In order to use HTTPS in development, a self-signed
25 | # certificate can be generated by running the following
26 | # Mix task:
27 | #
28 | # mix phx.gen.cert
29 | #
30 | # Run `mix help phx.gen.cert` for more information.
31 | #
32 | # The `http:` config above can be replaced with:
33 | #
34 | # https: [
35 | # port: 4001,
36 | # cipher_suite: :strong,
37 | # keyfile: "priv/cert/selfsigned_key.pem",
38 | # certfile: "priv/cert/selfsigned.pem"
39 | # ],
40 | #
41 | # If desired, both `http:` and `https:` keys can be
42 | # configured to run both http and https servers on
43 | # different ports.
44 |
45 | # Watch static and templates for browser reloading.
46 | config :nexus, NexusWeb.Endpoint,
47 | live_reload: [
48 | patterns: [
49 | ~r"priv/static/(?!uploads/).*(js|css|png|jpeg|jpg|gif|svg)$",
50 | ~r"lib/nexus_web/(controllers|live|components)/.*(ex|heex)$"
51 | ]
52 | ]
53 |
54 | # Do not include metadata nor timestamps in development logs
55 | config :logger, :console, format: "[$level] $message\n"
56 |
57 | # Set a higher stacktrace during development. Avoid configuring such
58 | # in production as building large stacktraces may be expensive.
59 | config :phoenix, :stacktrace_depth, 20
60 |
61 | # Initialize plugs at runtime for faster development compilation
62 | config :phoenix, :plug_init_mode, :runtime
63 |
64 | config :phoenix_live_view,
65 | # Include HEEx debug annotations as HTML comments in rendered markup
66 | debug_heex_annotations: true,
67 | # Enable helpful, but potentially expensive runtime checks
68 | enable_expensive_runtime_checks: true
69 |
--------------------------------------------------------------------------------
/nexus/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Note we also include the path to a cache manifest
4 | # containing the digested version of static files. This
5 | # manifest is generated by the `mix assets.deploy` task,
6 | # which you should run after static files are built and
7 | # before starting your production server.
8 | config :nexus, NexusWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
9 |
10 | # Do not print debug messages in production
11 | config :logger, level: :info
12 |
13 | # Runtime production configuration, including reading
14 | # of environment variables, is done on config/runtime.exs.
15 |
--------------------------------------------------------------------------------
/nexus/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/nexus start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 | read_ice_port_range! = fn ->
20 | case System.get_env("ICE_PORT_RANGE") do
21 | nil ->
22 | [0]
23 |
24 | raw_port_range ->
25 | case String.split(raw_port_range, "-", parts: 2) do
26 | [from, to] -> String.to_integer(from)..String.to_integer(to)
27 | _other -> raise "ICE_PORT_RANGE has to be in form of FROM-TO, passed: #{raw_port_range}"
28 | end
29 | end
30 | end
31 |
32 | if System.get_env("PHX_SERVER") do
33 | config :nexus, NexusWeb.Endpoint, server: true
34 | end
35 |
36 | config :nexus, ice_port_range: read_ice_port_range!.()
37 |
38 | if config_env() == :prod do
39 | # The secret key base is used to sign/encrypt cookies and other secrets.
40 | # A default value is used in config/dev.exs and config/test.exs but you
41 | # want to use a different value for prod and you most likely don't want
42 | # to check this value into version control, so we use an environment
43 | # variable instead.
44 | secret_key_base =
45 | System.get_env("SECRET_KEY_BASE") ||
46 | raise """
47 | environment variable SECRET_KEY_BASE is missing.
48 | You can generate one by calling: mix phx.gen.secret
49 | """
50 |
51 | host = System.get_env("PHX_HOST") || "example.com"
52 | port = String.to_integer(System.get_env("PORT") || "4000")
53 |
54 | config :nexus, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
55 |
56 | config :nexus, NexusWeb.Endpoint,
57 | url: [host: host, port: 443, scheme: "https"],
58 | http: [
59 | # Enable IPv6 and bind on all interfaces.
60 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
61 | # See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
62 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
63 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
64 | port: port
65 | ],
66 | secret_key_base: secret_key_base
67 |
68 | admin_username =
69 | System.get_env("ADMIN_USERNAME") || raise "Environment variable ADMIN_USERNAME is missing."
70 |
71 | admin_password =
72 | System.get_env("ADMIN_PASSWORD") || raise "Environment variable ADMIN_PASSWORD is missing."
73 |
74 | config :nexus,
75 | admin_username: admin_username,
76 | admin_password: admin_password
77 |
78 | # ## SSL Support
79 | #
80 | # To get SSL working, you will need to add the `https` key
81 | # to your endpoint configuration:
82 | #
83 | # config :nexus, NexusWeb.Endpoint,
84 | # https: [
85 | # ...,
86 | # port: 443,
87 | # cipher_suite: :strong,
88 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
89 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
90 | # ]
91 | #
92 | # The `cipher_suite` is set to `:strong` to support only the
93 | # latest and more secure SSL ciphers. This means old browsers
94 | # and clients may not be supported. You can set it to
95 | # `:compatible` for wider support.
96 | #
97 | # `:keyfile` and `:certfile` expect an absolute path to the key
98 | # and cert in disk or a relative path inside priv, for example
99 | # "priv/ssl/server.key". For all supported SSL configuration
100 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
101 | #
102 | # We also recommend setting `force_ssl` in your config/prod.exs,
103 | # ensuring no data is ever sent via http, always redirecting to https:
104 | #
105 | # config :nexus, NexusWeb.Endpoint,
106 | # force_ssl: [hsts: true]
107 | #
108 | # Check `Plug.SSL` for all available options in `force_ssl`.
109 | end
110 |
--------------------------------------------------------------------------------
/nexus/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :nexus, NexusWeb.Endpoint,
6 | http: [ip: {127, 0, 0, 1}, port: 4002],
7 | secret_key_base: "jnqiMUTku5atQODwJ2z4vMeU6kal0Av2djgp/I9f5jE6CGNUUZjxDBridCFC3xI2",
8 | server: false
9 |
10 | # Print only warnings and errors during test
11 | config :logger, level: :warning
12 |
13 | # Initialize plugs at runtime for faster test compilation
14 | config :phoenix, :plug_init_mode, :runtime
15 |
16 | config :phoenix_live_view,
17 | # Enable helpful, but potentially expensive runtime checks
18 | enable_expensive_runtime_checks: true
19 |
--------------------------------------------------------------------------------
/nexus/lib/nexus.ex:
--------------------------------------------------------------------------------
1 | defmodule Nexus do
2 | @moduledoc """
3 | Nexus keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/nexus/lib/nexus/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Nexus.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 |
6 | use Application
7 |
8 | @version Mix.Project.config()[:version]
9 |
10 | @spec version() :: String.t()
11 | def version(), do: @version
12 |
13 | @impl true
14 | def start(_type, _args) do
15 | children = [
16 | NexusWeb.Telemetry,
17 | {DNSCluster, query: Application.get_env(:nexus, :dns_cluster_query) || :ignore},
18 | {Phoenix.PubSub, name: Nexus.PubSub},
19 | # Start a worker by calling: Nexus.Worker.start_link(arg)
20 | # {Nexus.Worker, arg},
21 | # Start to serve requests, typically the last entry
22 | NexusWeb.Endpoint,
23 | NexusWeb.Presence,
24 | Nexus.PeerSupervisor,
25 | Nexus.Room,
26 | {Registry, name: Nexus.PeerRegistry, keys: :unique}
27 | ]
28 |
29 | # See https://hexdocs.pm/elixir/Supervisor.html
30 | # for other strategies and supported options
31 | opts = [strategy: :one_for_one, name: Nexus.Supervisor]
32 | Supervisor.start_link(children, opts)
33 | end
34 |
35 | # Tell Phoenix to update the endpoint configuration
36 | # whenever the application is updated.
37 | @impl true
38 | def config_change(changed, _new, removed) do
39 | NexusWeb.Endpoint.config_change(changed, removed)
40 | :ok
41 | end
42 | end
43 |
--------------------------------------------------------------------------------
/nexus/lib/nexus/peer_supervisor.ex:
--------------------------------------------------------------------------------
1 | defmodule Nexus.PeerSupervisor do
2 | @moduledoc false
3 |
4 | use DynamicSupervisor
5 |
6 | require Logger
7 |
8 | alias Nexus.Peer
9 |
10 | @spec start_link(any()) :: DynamicSupervisor.on_start_child()
11 | def start_link(arg) do
12 | DynamicSupervisor.start_link(__MODULE__, arg, name: __MODULE__)
13 | end
14 |
15 | @spec add_peer(String.t(), pid(), [String.t()]) :: {:ok, pid()}
16 | def add_peer(id, channel_pid, peer_ids) do
17 | peer_opts = [id, channel_pid, peer_ids]
18 | gen_server_opts = [name: Peer.registry_id(id)]
19 |
20 | child_spec = %{
21 | id: Peer,
22 | start: {Peer, :start_link, [peer_opts, gen_server_opts]},
23 | restart: :temporary
24 | }
25 |
26 | DynamicSupervisor.start_child(__MODULE__, child_spec)
27 | end
28 |
29 | @spec terminate_peer(Peer.id()) :: :ok
30 | def terminate_peer(peer) do
31 | try do
32 | peer |> Peer.registry_id() |> GenServer.stop(:shutdown)
33 | catch
34 | _exit_or_error, _e -> :ok
35 | end
36 |
37 | :ok
38 | end
39 |
40 | @impl true
41 | def init(_arg) do
42 | DynamicSupervisor.init(strategy: :one_for_one)
43 | end
44 | end
45 |
--------------------------------------------------------------------------------
/nexus/lib/nexus/room.ex:
--------------------------------------------------------------------------------
1 | defmodule Nexus.Room do
2 | @moduledoc false
3 |
4 | use GenServer
5 |
6 | require Logger
7 |
8 | alias Nexus.{Peer, PeerSupervisor}
9 | alias NexusWeb.PeerChannel
10 |
11 | @peer_ready_timeout_s 10
12 | @peer_limit 32
13 |
14 | @spec start_link(term()) :: GenServer.on_start()
15 | def start_link(args) do
16 | GenServer.start_link(__MODULE__, args, name: __MODULE__)
17 | end
18 |
19 | @spec add_peer(pid()) :: {:ok, Peer.id()} | {:error, :peer_limit_reached}
20 | def add_peer(channel_pid) do
21 | GenServer.call(__MODULE__, {:add_peer, channel_pid})
22 | end
23 |
24 | @spec mark_ready(Peer.id()) :: :ok
25 | def mark_ready(peer) do
26 | GenServer.call(__MODULE__, {:mark_ready, peer})
27 | end
28 |
29 | @impl true
30 | def init(_opts) do
31 | state = %{
32 | peers: %{},
33 | pending_peers: %{},
34 | peer_pid_to_id: %{}
35 | }
36 |
37 | {:ok, state}
38 | end
39 |
40 | @impl true
41 | def handle_call({:add_peer, _channel_pid}, _from, state)
42 | when map_size(state.pending_peers) + map_size(state.peers) == @peer_limit do
43 | Logger.warning("Unable to add new peer: reached peer limit (#{@peer_limit})")
44 | {:reply, {:error, :peer_limit_reached}, state}
45 | end
46 |
47 | @impl true
48 | def handle_call({:add_peer, channel_pid}, _from, state) do
49 | id = generate_id()
50 | Logger.info("New peer #{id} added")
51 | peer_ids = Map.keys(state.peers)
52 |
53 | {:ok, pid} = PeerSupervisor.add_peer(id, channel_pid, peer_ids)
54 | Process.monitor(pid)
55 |
56 | peer_data = %{pid: pid, channel: channel_pid}
57 |
58 | state =
59 | state
60 | |> put_in([:pending_peers, id], peer_data)
61 | |> put_in([:peer_pid_to_id, pid], id)
62 |
63 | Process.send_after(self(), {:peer_ready_timeout, id}, @peer_ready_timeout_s * 1000)
64 |
65 | {:reply, {:ok, id}, state}
66 | end
67 |
68 | @impl true
69 | def handle_call({:mark_ready, id}, _from, state)
70 | when is_map_key(state.pending_peers, id) do
71 | Logger.info("Peer #{id} ready")
72 | broadcast({:peer_added, id}, state)
73 |
74 | {peer_data, state} = pop_in(state, [:pending_peers, id])
75 | state = put_in(state, [:peers, id], peer_data)
76 |
77 | {:reply, :ok, state}
78 | end
79 |
80 | @impl true
81 | def handle_call({:mark_ready, id, _peer_ids}, _from, state) do
82 | Logger.debug("Peer #{id} was already marked as ready, ignoring")
83 |
84 | {:reply, :ok, state}
85 | end
86 |
87 | @impl true
88 | def handle_info({:peer_ready_timeout, peer}, state) do
89 | if is_map_key(state.pending_peers, peer) do
90 | Logger.warning(
91 | "Removing peer #{peer} which failed to mark itself as ready for #{@peer_ready_timeout_s} s"
92 | )
93 |
94 | :ok = PeerSupervisor.terminate_peer(peer)
95 | end
96 |
97 | {:noreply, state}
98 | end
99 |
100 | @impl true
101 | def handle_info({:DOWN, _ref, :process, pid, reason}, state) do
102 | {id, state} = pop_in(state, [:peer_pid_to_id, pid])
103 | Logger.info("Peer #{id} down with reason #{inspect(reason)}")
104 |
105 | state =
106 | cond do
107 | is_map_key(state.pending_peers, id) ->
108 | {peer_data, state} = pop_in(state, [:pending_peers, id])
109 | :ok = PeerChannel.close(peer_data.channel)
110 |
111 | state
112 |
113 | is_map_key(state.peers, id) ->
114 | {peer_data, state} = pop_in(state, [:peers, id])
115 | :ok = PeerChannel.close(peer_data.channel)
116 | broadcast({:peer_removed, id}, state)
117 |
118 | state
119 | end
120 |
121 | {:noreply, state}
122 | end
123 |
124 | defp generate_id, do: 5 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower)
125 |
126 | defp broadcast(msg, state) do
127 | Map.keys(state.peers)
128 | |> Stream.concat(Map.keys(state.pending_peers))
129 | |> Enum.each(&Peer.notify(&1, msg))
130 | end
131 | end
132 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use NexusWeb, :controller
9 | use NexusWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | formats: [:html, :json],
43 | layouts: [html: NexusWeb.Layouts]
44 |
45 | import Plug.Conn
46 |
47 | unquote(verified_routes())
48 | end
49 | end
50 |
51 | def live_view do
52 | quote do
53 | use Phoenix.LiveView,
54 | layout: {NexusWeb.Layouts, :app}
55 |
56 | unquote(html_helpers())
57 | end
58 | end
59 |
60 | def live_component do
61 | quote do
62 | use Phoenix.LiveComponent
63 |
64 | unquote(html_helpers())
65 | end
66 | end
67 |
68 | def html do
69 | quote do
70 | use Phoenix.Component
71 |
72 | # Import convenience functions from controllers
73 | import Phoenix.Controller,
74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
75 |
76 | # Include general helpers for rendering HTML
77 | unquote(html_helpers())
78 | end
79 | end
80 |
81 | defp html_helpers do
82 | quote do
83 | # HTML escaping functionality
84 | import Phoenix.HTML
85 | # Core UI components and translation
86 | import NexusWeb.CoreComponents
87 |
88 | # Shortcut for generating JS commands
89 | alias Phoenix.LiveView.JS
90 |
91 | # Routes generation with the ~p sigil
92 | unquote(verified_routes())
93 | end
94 | end
95 |
96 | def verified_routes do
97 | quote do
98 | use Phoenix.VerifiedRoutes,
99 | endpoint: NexusWeb.Endpoint,
100 | router: NexusWeb.Router,
101 | statics: NexusWeb.static_paths()
102 | end
103 | end
104 |
105 | @doc """
106 | When used, dispatch to the appropriate controller/live_view/etc.
107 | """
108 | defmacro __using__(which) when is_atom(which) do
109 | apply(__MODULE__, which, [])
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/channels/peer_channel.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.PeerChannel do
2 | @moduledoc false
3 |
4 | use NexusWeb, :channel
5 |
6 | require Logger
7 |
8 | alias Nexus.{Peer, Room}
9 | alias NexusWeb.Presence
10 |
11 | @spec send_offer(GenServer.server(), String.t()) :: :ok
12 | def send_offer(channel, offer) do
13 | GenServer.cast(channel, {:offer, offer})
14 | end
15 |
16 | @spec send_candidate(GenServer.server(), String.t()) :: :ok
17 | def send_candidate(channel, candidate) do
18 | GenServer.cast(channel, {:candidate, candidate})
19 | end
20 |
21 | @spec close(GenServer.server()) :: :ok
22 | def close(channel) do
23 | try do
24 | GenServer.stop(channel, :shutdown)
25 | catch
26 | _exit_or_error, _e -> :ok
27 | end
28 |
29 | :ok
30 | end
31 |
32 | @impl true
33 | def join("peer:signalling", _payload, socket) do
34 | pid = self()
35 | send(pid, :after_join)
36 |
37 | case Room.add_peer(pid) do
38 | {:ok, id} -> {:ok, assign(socket, :peer, id)}
39 | {:error, _reason} = error -> error
40 | end
41 | end
42 |
43 | @impl true
44 | def handle_in("sdp_answer", %{"body" => body}, socket) do
45 | :ok = Peer.apply_sdp_answer(socket.assigns.peer, body)
46 | {:noreply, socket}
47 | end
48 |
49 | @impl true
50 | def handle_in("sdp_offer", %{"body" => _body}, socket) do
51 | # TODO: renegotiate
52 | Logger.warning("Ignoring SDP offer sent by peer #{socket.assigns.peer}")
53 | {:noreply, socket}
54 | end
55 |
56 | @impl true
57 | def handle_in("ice_candidate", %{"body" => body}, socket) do
58 | Peer.add_ice_candidate(socket.assigns.peer, body)
59 | {:noreply, socket}
60 | end
61 |
62 | @impl true
63 | def handle_cast({:offer, sdp_offer}, socket) do
64 | push(socket, "sdp_offer", %{"body" => sdp_offer})
65 | {:noreply, socket}
66 | end
67 |
68 | @impl true
69 | def handle_cast({:candidate, candidate}, socket) do
70 | push(socket, "ice_candidate", %{"body" => candidate})
71 | {:noreply, socket}
72 | end
73 |
74 | @impl true
75 | def handle_info(:after_join, socket) do
76 | {:ok, _ref} = Presence.track(socket, socket.assigns.peer, %{})
77 | push(socket, "presence_state", Presence.list(socket))
78 | {:noreply, socket}
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/channels/presence.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.Presence do
2 | @moduledoc """
3 | Provides presence tracking to channels and processes.
4 |
5 | See the [`Phoenix.Presence`](https://hexdocs.pm/phoenix/Phoenix.Presence.html)
6 | docs for more details.
7 | """
8 | use Phoenix.Presence,
9 | otp_app: :nexus,
10 | pubsub_server: Nexus.PubSub
11 | end
12 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | channel "stream:*", NexusWeb.StreamChannel
5 | channel "peer:*", NexusWeb.PeerChannel
6 |
7 | @impl true
8 | def connect(_params, socket, _connect_info) do
9 | {:ok, assign(socket, :user_id, generate_id())}
10 | end
11 |
12 | @impl true
13 | def id(socket), do: "user_socket:#{socket.assigns.user_id}"
14 |
15 | defp generate_id do
16 | 10
17 | |> :crypto.strong_rand_bytes()
18 | |> Base.url_encode64()
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.Layouts do
2 | @moduledoc """
3 | This module holds different layouts used by your application.
4 |
5 | See the `layouts` directory for all templates available.
6 | The "root" layout is a skeleton rendered as part of the
7 | application router. The "app" layout is set as the default
8 | layout on both `use NexusWeb, :controller` and
9 | `use NexusWeb, :live_view`.
10 | """
11 | use NexusWeb, :html
12 |
13 | embed_templates "layouts/*"
14 | end
15 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
27 |
39 |
40 |
41 | <.flash_group flash={@flash} />
42 | {@inner_content}
43 |
44 |
45 |
48 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title suffix=" · Nexus">
8 | {assigns[:page_title]}
9 |
10 |
11 |
13 |
14 |
15 | {@inner_content}
16 |
17 |
18 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.ErrorHTML do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on HTML requests.
4 |
5 | See config/config.exs.
6 | """
7 | use NexusWeb, :html
8 |
9 | # If you want to customize your error pages,
10 | # uncomment the embed_templates/1 call below
11 | # and add pages to the error directory:
12 | #
13 | # * lib/nexus_web/controllers/error_html/404.html.heex
14 | # * lib/nexus_web/controllers/error_html/500.html.heex
15 | #
16 | # embed_templates "error_html/*"
17 |
18 | # The default is to render a plain text page based on
19 | # the template name. For example, "404.html" becomes
20 | # "Not Found".
21 | def render(template, _assigns) do
22 | Phoenix.Controller.status_message_from_template(template)
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.ErrorJSON do
2 | @moduledoc """
3 | This module is invoked by your endpoint in case of errors on JSON requests.
4 |
5 | See config/config.exs.
6 | """
7 |
8 | # If you want to customize a particular status code,
9 | # you may add your own clauses, such as:
10 | #
11 | # def render("500.json", _assigns) do
12 | # %{errors: %{detail: "Internal Server Error"}}
13 | # end
14 |
15 | # By default, Phoenix returns the status message from
16 | # the template name. For example, "404.json" becomes
17 | # "Not Found".
18 | def render(template, _assigns) do
19 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.PageController do
2 | use NexusWeb, :controller
3 |
4 | def home(conn, _params) do
5 | render(conn, :home, page_title: "Home")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/controllers/page_html.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.PageHTML do
2 | @moduledoc """
3 | This module contains pages rendered by PageController.
4 |
5 | See the `page_html` directory for all templates available.
6 | """
7 | use NexusWeb, :html
8 |
9 | embed_templates "page_html/*"
10 | end
11 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/controllers/page_html/home.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 | Unable to join the room
4 |
5 |
6 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :nexus
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_nexus_key",
10 | signing_salt: "BnOeka5n",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/socket", NexusWeb.UserSocket,
15 | websocket: true,
16 | longpoll: false
17 |
18 | socket "/live", Phoenix.LiveView.Socket,
19 | websocket: [connect_info: [session: @session_options]],
20 | longpoll: [connect_info: [session: @session_options]]
21 |
22 | # Serve at "/" the static files from "priv/static" directory.
23 | #
24 | # You should set gzip to true if you are running phx.digest
25 | # when deploying your static files in production.
26 | plug Plug.Static,
27 | at: "/",
28 | from: :nexus,
29 | gzip: false,
30 | only: NexusWeb.static_paths()
31 |
32 | # Code reloading can be explicitly enabled under the
33 | # :code_reloader configuration of your endpoint.
34 | if code_reloading? do
35 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
36 | plug Phoenix.LiveReloader
37 | plug Phoenix.CodeReloader
38 | end
39 |
40 | plug Phoenix.LiveDashboard.RequestLogger,
41 | param_key: "request_logger",
42 | cookie_key: "request_logger"
43 |
44 | plug Plug.RequestId
45 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
46 |
47 | plug Plug.Parsers,
48 | parsers: [:urlencoded, :multipart, :json],
49 | pass: ["*/*"],
50 | json_decoder: Phoenix.json_library()
51 |
52 | plug Plug.MethodOverride
53 | plug Plug.Head
54 | plug Plug.Session, @session_options
55 | plug NexusWeb.Router
56 | end
57 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.Router do
2 | use NexusWeb, :router
3 |
4 | import Phoenix.LiveDashboard.Router
5 |
6 | pipeline :browser do
7 | plug :accepts, ["html"]
8 | plug :fetch_session
9 | plug :fetch_live_flash
10 | plug :put_root_layout, html: {NexusWeb.Layouts, :root}
11 | plug :protect_from_forgery
12 | plug :put_secure_browser_headers
13 | end
14 |
15 | pipeline :auth do
16 | plug :admin_auth
17 | end
18 |
19 | scope "/", NexusWeb do
20 | pipe_through :browser
21 |
22 | get "/", PageController, :home
23 | end
24 |
25 | scope "/admin", NexusWeb do
26 | pipe_through :auth
27 | pipe_through :browser
28 |
29 | live_dashboard "/dashboard",
30 | metrics: NexusWeb.Telemetry,
31 | additional_pages: [exwebrtc: ExWebRTCDashboard]
32 | end
33 |
34 | defp admin_auth(conn, _opts) do
35 | username = Application.fetch_env!(:nexus, :admin_username)
36 | password = Application.fetch_env!(:nexus, :admin_password)
37 | Plug.BasicAuth.basic_auth(conn, username: username, password: password)
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/nexus/lib/nexus_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | summary("phoenix.channel_joined.duration",
47 | unit: {:native, :millisecond}
48 | ),
49 | summary("phoenix.channel_handled_in.duration",
50 | tags: [:event],
51 | unit: {:native, :millisecond}
52 | ),
53 |
54 | # VM Metrics
55 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
56 | summary("vm.total_run_queue_lengths.total"),
57 | summary("vm.total_run_queue_lengths.cpu"),
58 | summary("vm.total_run_queue_lengths.io")
59 | ]
60 | end
61 |
62 | defp periodic_measurements do
63 | [
64 | # A module, function and arguments to be invoked periodically.
65 | # This function must call :telemetry.execute/3 and a metric must be added above.
66 | # {NexusWeb, :count_users, []}
67 | ]
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/nexus/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Nexus.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :nexus,
7 | version: "0.4.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps(),
13 |
14 | # dialyzer
15 | dialyzer: [
16 | plt_local_path: "_dialyzer",
17 | plt_core_path: "_dialyzer"
18 | ]
19 | ]
20 | end
21 |
22 | def application do
23 | [
24 | mod: {Nexus.Application, []},
25 | extra_applications: [:logger, :runtime_tools]
26 | ]
27 | end
28 |
29 | defp elixirc_paths(:test), do: ["lib", "test/support"]
30 | defp elixirc_paths(_), do: ["lib"]
31 |
32 | defp deps do
33 | [
34 | {:phoenix, "~> 1.7.12"},
35 | {:phoenix_html, "~> 4.0"},
36 | {:phoenix_live_reload, "~> 1.2", only: :dev},
37 | {:phoenix_live_view, "~> 1.0"},
38 | {:floki, ">= 0.30.0", only: :test},
39 | {:phoenix_live_dashboard, "~> 0.8.3"},
40 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
41 | {:tailwind, "~> 0.2", runtime: Mix.env() == :dev},
42 | {:heroicons,
43 | github: "tailwindlabs/heroicons",
44 | tag: "v2.1.1",
45 | sparse: "optimized",
46 | app: false,
47 | compile: false,
48 | depth: 1},
49 | {:telemetry_metrics, "~> 1.0"},
50 | {:telemetry_poller, "~> 1.0"},
51 | {:jason, "~> 1.2"},
52 | {:dns_cluster, "~> 0.1.1"},
53 | {:bandit, "~> 1.2"},
54 | {:ex_webrtc, "~> 0.8.0"},
55 | {:ex_webrtc_dashboard, "~> 0.8.0"},
56 |
57 | # Dialyzer and credo
58 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false},
59 | {:credo, ">= 0.0.0", only: :dev, runtime: false}
60 | ]
61 | end
62 |
63 | defp aliases do
64 | [
65 | setup: ["deps.get", "assets.setup", "assets.build"],
66 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
67 | "assets.build": ["tailwind nexus", "esbuild nexus"],
68 | "assets.deploy": [
69 | "tailwind nexus --minify",
70 | "esbuild nexus --minify",
71 | "phx.digest"
72 | ],
73 | "assets.format": &lint_and_format_assets/1,
74 | "assets.check": &check_assets/1
75 | ]
76 | end
77 |
78 | defp lint_and_format_assets(_args) do
79 | with {_, 0} <- execute_npm_command(["ci"]),
80 | {_, 0} <- execute_npm_command(["run", "lint"]),
81 | {_, 0} <- execute_npm_command(["run", "format"]) do
82 | :ok
83 | else
84 | {cmd, rc} ->
85 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}")
86 | exit({:shutdown, rc})
87 | end
88 | end
89 |
90 | defp check_assets(_args) do
91 | with {_, 0} <- execute_npm_command(["ci"]),
92 | {_, 0} <- execute_npm_command(["run", "check"]) do
93 | :ok
94 | else
95 | {cmd, rc} ->
96 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}")
97 | exit({:shutdown, rc})
98 | end
99 | end
100 |
101 | defp execute_npm_command(command) do
102 | {_stream, rc} = System.cmd("npm", ["--prefix=assets"] ++ command, into: IO.stream())
103 | {command, rc}
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/nexus/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elixir-webrtc/apps/4502fee12b35b1e9e0b64d613dae70b63a07e78d/nexus/priv/static/favicon.ico
--------------------------------------------------------------------------------
/nexus/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/nexus/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eu
3 |
4 | cd -P -- "$(dirname -- "$0")"
5 | PHX_SERVER=true exec ./nexus start
6 |
--------------------------------------------------------------------------------
/nexus/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\nexus" start
3 |
--------------------------------------------------------------------------------
/nexus/test/nexus_web/controllers/error_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.ErrorHTMLTest do
2 | use NexusWeb.ConnCase, async: true
3 |
4 | # Bring render_to_string/4 for testing custom views
5 | import Phoenix.Template
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(NexusWeb.ErrorHTML, "404", "html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(NexusWeb.ErrorHTML, "500", "html", []) ==
13 | "Internal Server Error"
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/nexus/test/nexus_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.ErrorJSONTest do
2 | use NexusWeb.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert NexusWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6 | end
7 |
8 | test "renders 500" do
9 | assert NexusWeb.ErrorJSON.render("500.json", %{}) ==
10 | %{errors: %{detail: "Internal Server Error"}}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/nexus/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use NexusWeb.ChannelCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # Import conveniences for testing with channels
23 | import Phoenix.ChannelTest
24 | import NexusWeb.ChannelCase
25 |
26 | # The default endpoint for testing
27 | @endpoint NexusWeb.Endpoint
28 | end
29 | end
30 |
31 | setup _tags do
32 | :ok
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/nexus/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule NexusWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use NexusWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint NexusWeb.Endpoint
24 |
25 | use NexusWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import NexusWeb.ConnCase
31 | end
32 | end
33 |
34 | setup _tags do
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/nexus/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/recognizer/.dockerignore:
--------------------------------------------------------------------------------
1 | # This file excludes paths from the Docker build context.
2 | #
3 | # By default, Docker's build context includes all files (and folders) in the
4 | # current directory. Even if a file isn't copied into the container it is still sent to
5 | # the Docker daemon.
6 | #
7 | # There are multiple reasons to exclude files from the build context:
8 | #
9 | # 1. Prevent nested folders from being copied into the container (ex: exclude
10 | # /assets/node_modules when copying /assets)
11 | # 2. Reduce the size of the build context and improve build time (ex. /build, /deps, /doc)
12 | # 3. Avoid sending files containing sensitive information
13 | #
14 | # More information on using .dockerignore is available here:
15 | # https://docs.docker.com/engine/reference/builder/#dockerignore-file
16 |
17 | .dockerignore
18 |
19 | # Ignore git, but keep git HEAD and refs to access current commit hash if needed:
20 | #
21 | # $ cat .git/HEAD | awk '{print ".git/"$2}' | xargs cat
22 | # d0b8727759e1e0e7aa3d41707d12376e373d5ecc
23 | .git
24 | !.git/HEAD
25 | !.git/refs
26 |
27 | # Common development/test artifacts
28 | /cover/
29 | /doc/
30 | /test/
31 | /tmp/
32 | .elixir_ls
33 |
34 | # Mix artifacts
35 | /_build/
36 | /deps/
37 | *.ez
38 |
39 | # Generated on crash by the VM
40 | erl_crash.dump
41 |
42 | # Static artifacts - These should be fetched and built inside the Docker image
43 | /assets/node_modules/
44 | /priv/static/assets/
45 | /priv/static/cache_manifest.json
46 |
--------------------------------------------------------------------------------
/recognizer/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:phoenix],
3 | plugins: [Phoenix.LiveView.HTMLFormatter],
4 | inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}"]
5 | ]
6 |
--------------------------------------------------------------------------------
/recognizer/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Temporary files, for example, from tests.
23 | /tmp/
24 |
25 | # Ignore package tarball (built via "mix hex.build").
26 | recognizer-*.tar
27 |
28 | # Ignore assets that are produced by build tools.
29 | /priv/static/assets/
30 |
31 | # Ignore digested assets cache.
32 | /priv/static/cache_manifest.json
33 |
34 | # In case you use Node.js/npm, you want to ignore these.
35 | npm-debug.log
36 | /assets/node_modules/
37 |
38 | /_dialyzer/
39 |
--------------------------------------------------------------------------------
/recognizer/Dockerfile:
--------------------------------------------------------------------------------
1 | # Find eligible builder and runner images on Docker Hub. We use Ubuntu/Debian
2 | # instead of Alpine to avoid DNS resolution issues in production.
3 | #
4 | # https://hub.docker.com/r/hexpm/elixir/tags?page=1&name=ubuntu
5 | # https://hub.docker.com/_/ubuntu?tab=tags
6 | #
7 | # This file is based on these images:
8 | #
9 | # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
10 | # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20231009-slim - for the release image
11 | # - https://pkgs.org/ - resource for finding needed packages
12 | # - Ex: hexpm/elixir:1.16.0-erlang-26.2.1-debian-bullseye-20231009-slim
13 | ARG ELIXIR_VERSION=1.17.2
14 | ARG OTP_VERSION=27.0.1
15 | ARG DEBIAN_VERSION=bookworm-20240701-slim
16 |
17 | ARG BUILDER_IMAGE="hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-debian-${DEBIAN_VERSION}"
18 | ARG RUNNER_IMAGE="debian:${DEBIAN_VERSION}"
19 |
20 | FROM ${BUILDER_IMAGE} AS builder
21 |
22 | # install build dependencies
23 | RUN apt-get update -y && apt-get install -y \
24 | build-essential \
25 | libssl-dev \
26 | curl \
27 | pkg-config \
28 | git \
29 | libsrtp2-dev \
30 | libavcodec-dev \
31 | libavformat-dev \
32 | libavutil-dev \
33 | libswscale-dev \
34 | libavdevice-dev && \
35 | apt-get clean && \
36 | rm -f /var/lib/apt/lists/*_*
37 |
38 | # prepare build dir
39 | WORKDIR /app
40 |
41 | # install hex + rebar
42 | RUN mix local.hex --force && \
43 | mix local.rebar --force
44 |
45 | # set build ENV
46 | ENV MIX_ENV="prod"
47 |
48 | # install mix dependencies
49 | COPY mix.exs mix.lock ./
50 | RUN mix deps.get --only $MIX_ENV
51 | RUN mkdir config
52 |
53 | # copy compile-time config files before we compile dependencies
54 | # to ensure any relevant config change will trigger the dependencies
55 | # to be re-compiled.
56 | COPY config/config.exs config/${MIX_ENV}.exs config/
57 | RUN mix deps.compile
58 |
59 | COPY priv priv
60 |
61 | COPY lib lib
62 |
63 | COPY assets assets
64 |
65 | # compile assets
66 | RUN mix assets.deploy
67 |
68 | # Compile the release
69 | RUN mix compile
70 |
71 | # Changes to config/runtime.exs don't require recompiling the code
72 | COPY config/runtime.exs config/
73 |
74 | COPY rel rel
75 | RUN mix release
76 |
77 | # start a new build stage so that the final image will only contain
78 | # the compiled release and other runtime necessities
79 | FROM ${RUNNER_IMAGE}
80 |
81 | RUN apt-get update -y && \
82 | apt-get install -y libstdc++6 \
83 | openssl \
84 | libncurses5 \
85 | locales \
86 | ca-certificates \
87 | libsrtp2-dev \
88 | libavcodec-dev \
89 | libavformat-dev \
90 | libavutil-dev \
91 | libswscale-dev \
92 | libavdevice-dev \
93 | && apt-get clean \
94 | && rm -f /var/lib/apt/lists/*_*
95 |
96 | # Set the locale
97 | RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
98 |
99 | ENV LANG=en_US.UTF-8
100 | ENV LANGUAGE=en_US:en
101 | ENV LC_ALL=en_US.UTF-8
102 |
103 | WORKDIR "/app"
104 | RUN chown nobody /app
105 |
106 | # set runner ENV
107 | ENV MIX_ENV="prod"
108 |
109 | # without setting this, bumblebee tries to use /nonexistent directory,
110 | # which does not exist and cannot be created
111 | ENV BUMBLEBEE_CACHE_DIR=/app/bin
112 |
113 | # Only copy the final release from the build stage
114 | COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/recognizer ./
115 |
116 | USER nobody
117 |
118 | # If using an environment that doesn't automatically reap zombie processes, it is
119 | # advised to add an init process such as tini via `apt-get install`
120 | # above and adding an entrypoint. See https://github.com/krallin/tini for details
121 | # ENTRYPOINT ["/tini", "--"]
122 |
123 | CMD ["/app/bin/server"]
124 |
--------------------------------------------------------------------------------
/recognizer/README.md:
--------------------------------------------------------------------------------
1 | # Recognizer
2 |
3 | Phoenix app for real-time image recognition using [Elixir WebRTC](https://github.com/elixir-webrtc) and [Elixir Nx](https://github.com/elixir-nx/nx).
4 |
5 | To start your Phoenix server:
6 |
7 | * Run `mix setup` to install and setup dependencies
8 | * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server`
9 |
10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
11 |
12 | ## Running with Docker
13 |
14 | You can also run Recognizer using Docker.
15 |
16 | Build an image (or use `ghcr.io/elixir-webrtc/apps/recognizer:latest`):
17 |
18 | ```
19 | docker build -t recognizer .
20 | ```
21 |
22 | and run:
23 |
24 | ```
25 | docker run -e SECRET_KEY_BASE="secret" -e PHX_HOST=localhost --network host recognizer
26 | ```
27 |
28 | Note that secret has to be at least 64 bytes long.
29 | You can generate one with `mix phx.gen.secret` or `head -c64 /dev/urandom | base64`.
30 |
31 | If you are running on MacOS, instead of using `--network host` option, you have to explicitly publish ports:
32 |
33 | ```
34 | docker run -e SECRET_KEY_BASE="secert" -e PHX_HOST=localhost -p 4000:4000 -p 50000-50010/udp recognizer
35 | ```
36 |
37 |
--------------------------------------------------------------------------------
/recognizer/assets/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "es5",
4 | "tabWidth": 2,
5 | "useTabs": false,
6 | "semi": true,
7 | "printWidth": 80
8 | }
9 |
--------------------------------------------------------------------------------
/recognizer/assets/css/app.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss/base';
2 | @import 'tailwindcss/components';
3 | @import 'tailwindcss/utilities';
4 |
5 | /* This file is for your main application CSS */
6 |
7 | /* Hiding scrollbar for IE, Edge and Firefox */
8 | main {
9 | scrollbar-width: none; /* Firefox */
10 | -ms-overflow-style: none; /* IE and Edge */
11 | }
12 |
13 | main::-webkit-scrollbar {
14 | display: none;
15 | }
16 |
--------------------------------------------------------------------------------
/recognizer/assets/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import globals from "globals";
2 | import pluginJs from "@eslint/js";
3 | import prettierPlugin from 'eslint-plugin-prettier';
4 | import prettierConfig from 'eslint-config-prettier';
5 |
6 | export default [
7 | {
8 | files: ['**/*.js'],
9 | languageOptions: {
10 | ecmaVersion: 2021,
11 | sourceType: "module",
12 | globals: globals.browser
13 | },
14 | plugins: {
15 | prettier: prettierPlugin
16 | },
17 | rules: {
18 | "prettier/prettier": "error",
19 | "no-unused-vars": [
20 | "error",
21 | {
22 | "argsIgnorePattern": "^_",
23 | "varsIgnorePattern": "^_"
24 | }
25 | ]
26 | },
27 | settings: {
28 | prettier: prettierConfig
29 | }
30 | },
31 | pluginJs.configs.recommended,
32 | ];
33 |
--------------------------------------------------------------------------------
/recognizer/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // If you want to use Phoenix channels, run `mix help phx.gen.channel`
2 | // to get started and then uncomment the line below.
3 | // import "./user_socket.js"
4 |
5 | // You can include dependencies in two ways.
6 | //
7 | // The simplest option is to put them in assets/vendor and
8 | // import them using relative paths:
9 | //
10 | // import "../vendor/some-package.js"
11 | //
12 | // Alternatively, you can `npm install some-package --prefix assets` and import
13 | // them using a path starting with the package name:
14 | //
15 | // import "some-package"
16 | //
17 |
18 | // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
19 | import 'phoenix_html';
20 | // Establish Phoenix Socket and LiveView configuration.
21 | import { Socket } from 'phoenix';
22 | import { LiveSocket } from 'phoenix_live_view';
23 | import topbar from '../vendor/topbar';
24 |
25 | import { Room } from './room.js';
26 |
27 | let Hooks = {};
28 | Hooks.Room = Room;
29 |
30 | let csrfToken = document
31 | .querySelector("meta[name='csrf-token']")
32 | .getAttribute('content');
33 | let liveSocket = new LiveSocket('/live', Socket, {
34 | params: { _csrf_token: csrfToken },
35 | hooks: Hooks,
36 | });
37 |
38 | // Show progress bar on live navigation and form submits
39 | topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' });
40 | window.addEventListener('phx:page-loading-start', (_info) => topbar.show(300));
41 | window.addEventListener('phx:page-loading-stop', (_info) => topbar.hide());
42 |
43 | // connect if there are any LiveViews on the page
44 | liveSocket.connect();
45 |
46 | // expose liveSocket on window for web console debug logs and latency simulation:
47 | // >> liveSocket.enableDebug()
48 | // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
49 | // >> liveSocket.disableLatencySim()
50 | // window.liveSocket = liveSocket
51 |
--------------------------------------------------------------------------------
/recognizer/assets/js/room.js:
--------------------------------------------------------------------------------
1 | import { Socket } from 'phoenix';
2 |
3 | const locArray = window.location.pathname.split('/');
4 | const roomId = locArray[locArray.length - 1];
5 |
6 | const pcConfig = { iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] };
7 |
8 | const videoPlayer = document.getElementById('videoPlayer');
9 | const button = document.getElementById('leaveButton');
10 | const imgpred = document.getElementById('imgpred');
11 | const imgscore = document.getElementById('imgscore');
12 | const time = document.getElementById('time');
13 |
14 | let localStream;
15 | let socket;
16 | let channel;
17 | let pc;
18 |
19 | async function connect() {
20 | console.log('Connecting');
21 | button.onclick = disconnect;
22 |
23 | localStream = await navigator.mediaDevices.getUserMedia({
24 | audio: true,
25 | video: {
26 | width: { ideal: 320 },
27 | height: { ideal: 160 },
28 | frameRate: { ideal: 15 },
29 | },
30 | });
31 |
32 | videoPlayer.srcObject = localStream;
33 |
34 | socket = new Socket('/socket', {});
35 | socket.connect();
36 |
37 | channel = socket.channel('room:' + roomId, {});
38 | channel.onClose((_) => {
39 | window.location.href = '/';
40 | });
41 |
42 | channel
43 | .join()
44 | .receive('ok', (resp) => {
45 | console.log('Joined successfully', resp);
46 | })
47 | .receive('error', (resp) => {
48 | console.log('Unable to join', resp);
49 | window.location.href = '/';
50 | });
51 |
52 | channel.on('signaling', (msg) => {
53 | if (msg.type == 'answer') {
54 | console.log('Setting remote answer');
55 | pc.setRemoteDescription(msg);
56 | } else if (msg.type == 'ice') {
57 | console.log('Adding ICE candidate');
58 | pc.addIceCandidate(msg.data);
59 | }
60 | });
61 |
62 | channel.on('imgReco', (msg) => {
63 | const pred = msg['predictions'][0];
64 | imgpred.innerText = pred['label'];
65 | imgscore.innerText = pred['score'].toFixed(3);
66 | });
67 |
68 | channel.on('sessionTime', (msg) => {
69 | time.innerText = msg['time'];
70 | });
71 |
72 | pc = new RTCPeerConnection(pcConfig);
73 | pc.onicecandidate = (ev) => {
74 | channel.push(
75 | 'signaling',
76 | JSON.stringify({ type: 'ice', data: ev.candidate })
77 | );
78 | };
79 | pc.addTrack(localStream.getAudioTracks()[0]);
80 | pc.addTrack(localStream.getVideoTracks()[0]);
81 |
82 | const offer = await pc.createOffer();
83 | await pc.setLocalDescription(offer);
84 | channel.push('signaling', JSON.stringify(offer));
85 | }
86 |
87 | function disconnect() {
88 | console.log('Disconnecting');
89 | localStream.getTracks().forEach((track) => track.stop());
90 | videoPlayer.srcObject = null;
91 |
92 | if (typeof channel !== 'undefined') {
93 | channel.leave();
94 | }
95 |
96 | if (typeof socket !== 'undefined') {
97 | socket.disconnect();
98 | }
99 |
100 | if (typeof pc !== 'undefined') {
101 | pc.close();
102 | }
103 | }
104 |
105 | export const Room = {
106 | mounted() {
107 | connect();
108 | },
109 | };
110 |
--------------------------------------------------------------------------------
/recognizer/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "assets",
3 | "version": "0.1.0",
4 | "description": "",
5 | "main": "",
6 | "scripts": {
7 | "lint": "eslint 'js/**/*.js' --fix",
8 | "format": "prettier --write 'js/**/*.js' 'css/**/*.css'",
9 | "check": "prettier --check 'js/**/*.js' 'css/**/*.css'"
10 | },
11 | "keywords": [],
12 | "author": "",
13 | "license": "Apache-2.0",
14 | "devDependencies": {
15 | "@eslint/eslintrc": "^3.1.0",
16 | "@eslint/js": "^9.7.0",
17 | "eslint": "^9.7.0",
18 | "eslint-config-prettier": "^9.1.0",
19 | "eslint-plugin-prettier": "^5.1.3",
20 | "globals": "^15.8.0",
21 | "prettier": "^3.3.3"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/recognizer/assets/tailwind.config.js:
--------------------------------------------------------------------------------
1 | // See the Tailwind configuration guide for advanced usage
2 | // https://tailwindcss.com/docs/configuration
3 |
4 | const plugin = require("tailwindcss/plugin")
5 | const fs = require("fs")
6 | const path = require("path")
7 |
8 | module.exports = {
9 | content: [
10 | "./js/**/*.js",
11 | "../lib/recognizer_web.ex",
12 | "../lib/recognizer_web/**/*.*ex"
13 | ],
14 | theme: {
15 | extend: {
16 | colors: {
17 | brand: "#4339AC",
18 | }
19 | },
20 | },
21 | plugins: [
22 | require("@tailwindcss/forms"),
23 | // Allows prefixing tailwind classes with LiveView classes to add rules
24 | // only when LiveView classes are applied, for example:
25 | //
26 | //
27 | //
28 | plugin(({addVariant}) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
29 | plugin(({addVariant}) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),
30 | plugin(({addVariant}) => addVariant("phx-submit-loading", [".phx-submit-loading&", ".phx-submit-loading &"])),
31 | plugin(({addVariant}) => addVariant("phx-change-loading", [".phx-change-loading&", ".phx-change-loading &"])),
32 |
33 | // Embeds Heroicons (https://heroicons.com) into your app.css bundle
34 | // See your `CoreComponents.icon/1` for more information.
35 | //
36 | plugin(function({matchComponents, theme}) {
37 | let iconsDir = path.join(__dirname, "../deps/heroicons/optimized")
38 | let values = {}
39 | let icons = [
40 | ["", "/24/outline"],
41 | ["-solid", "/24/solid"],
42 | ["-mini", "/20/solid"]
43 | ]
44 | icons.forEach(([suffix, dir]) => {
45 | fs.readdirSync(path.join(iconsDir, dir)).forEach(file => {
46 | let name = path.basename(file, ".svg") + suffix
47 | values[name] = {name, fullPath: path.join(iconsDir, dir, file)}
48 | })
49 | })
50 | matchComponents({
51 | "hero": ({name, fullPath}) => {
52 | let content = fs.readFileSync(fullPath).toString().replace(/\r?\n|\r/g, "")
53 | return {
54 | [`--hero-${name}`]: `url('data:image/svg+xml;utf8,${content}')`,
55 | "-webkit-mask": `var(--hero-${name})`,
56 | "mask": `var(--hero-${name})`,
57 | "mask-repeat": "no-repeat",
58 | "background-color": "currentColor",
59 | "vertical-align": "middle",
60 | "display": "inline-block",
61 | "width": theme("spacing.5"),
62 | "height": theme("spacing.5")
63 | }
64 | }
65 | }, {values})
66 | })
67 | ]
68 | }
69 |
--------------------------------------------------------------------------------
/recognizer/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 |
7 | # General application configuration
8 | import Config
9 |
10 | config :recognizer,
11 | generators: [timestamp_type: :utc_datetime]
12 |
13 | # Configures the endpoint
14 | config :recognizer, RecognizerWeb.Endpoint,
15 | url: [host: "localhost"],
16 | adapter: Phoenix.Endpoint.Cowboy2Adapter,
17 | render_errors: [
18 | formats: [html: RecognizerWeb.ErrorHTML, json: RecognizerWeb.ErrorJSON],
19 | layout: false
20 | ],
21 | pubsub_server: Recognizer.PubSub,
22 | live_view: [signing_salt: "S8H9IjcD"]
23 |
24 | # Configure esbuild (the version is required)
25 | config :esbuild,
26 | version: "0.17.11",
27 | default: [
28 | args:
29 | ~w(js/app.js js/room.js --bundle --target=es2017 --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
30 | cd: Path.expand("../assets", __DIR__),
31 | env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
32 | ]
33 |
34 | # Configure tailwind (the version is required)
35 | config :tailwind,
36 | version: "3.4.4",
37 | default: [
38 | args: ~w(
39 | --config=tailwind.config.js
40 | --input=css/app.css
41 | --output=../priv/static/assets/app.css
42 | ),
43 | cd: Path.expand("../assets", __DIR__)
44 | ]
45 |
46 | # Configures Elixir's Logger
47 | config :logger, :console,
48 | format: "$time $metadata[$level] $message\n",
49 | metadata: [:request_id]
50 |
51 | # Use Jason for JSON parsing in Phoenix
52 | config :phoenix, :json_library, Jason
53 |
54 | config :nx, default_backend: EXLA.Backend
55 |
56 | config :recognizer, max_rooms: 5, max_session_time_s: 200
57 |
58 | # Import environment specific config. This must remain at the bottom
59 | # of this file so it overrides the configuration defined above.
60 | import_config "#{config_env()}.exs"
61 |
--------------------------------------------------------------------------------
/recognizer/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we can use it
8 | # to bundle .js and .css sources.
9 | config :recognizer, RecognizerWeb.Endpoint,
10 | # Binding to loopback ipv4 address prevents access from other machines.
11 | # Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
12 | http: [ip: {127, 0, 0, 1}, port: 4000],
13 | check_origin: false,
14 | code_reloader: true,
15 | debug_errors: true,
16 | secret_key_base: "ep2F4amvF/PSJgRn86LbwcxOOY841sKkNePN6O7zAOFtgCHKtIag091I7qqHxtaN",
17 | watchers: [
18 | esbuild: {Esbuild, :install_and_run, [:default, ~w(--sourcemap=inline --watch)]},
19 | tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]}
20 | ]
21 |
22 | # ## SSL Support
23 | #
24 | # In order to use HTTPS in development, a self-signed
25 | # certificate can be generated by running the following
26 | # Mix task:
27 | #
28 | # mix phx.gen.cert
29 | #
30 | # Run `mix help phx.gen.cert` for more information.
31 | #
32 | # The `http:` config above can be replaced with:
33 | #
34 | # https: [
35 | # port: 4001,
36 | # cipher_suite: :strong,
37 | # keyfile: "priv/cert/selfsigned_key.pem",
38 | # certfile: "priv/cert/selfsigned.pem"
39 | # ],
40 | #
41 | # If desired, both `http:` and `https:` keys can be
42 | # configured to run both http and https servers on
43 | # different ports.
44 |
45 | # Watch static and templates for browser reloading.
46 | config :recognizer, RecognizerWeb.Endpoint,
47 | live_reload: [
48 | patterns: [
49 | ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
50 | ~r"lib/recognizer_web/(controllers|live|components)/.*(ex|heex)$"
51 | ]
52 | ]
53 |
54 | config :recognizer,
55 | admin_username: "admin",
56 | admin_password: "admin"
57 |
58 | # Do not include metadata nor timestamps in development logs
59 | config :logger, :console, level: :info, format: "[$level] $message\n"
60 |
61 | # Set a higher stacktrace during development. Avoid configuring such
62 | # in production as building large stacktraces may be expensive.
63 | config :phoenix, :stacktrace_depth, 20
64 |
65 | # Initialize plugs at runtime for faster development compilation
66 | config :phoenix, :plug_init_mode, :runtime
67 |
68 | # Include HEEx debug annotations as HTML comments in rendered markup
69 | config :phoenix_live_view, :debug_heex_annotations, true
70 |
--------------------------------------------------------------------------------
/recognizer/config/prod.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # Note we also include the path to a cache manifest
4 | # containing the digested version of static files. This
5 | # manifest is generated by the `mix assets.deploy` task,
6 | # which you should run after static files are built and
7 | # before starting your production server.
8 | config :recognizer, RecognizerWeb.Endpoint,
9 | cache_static_manifest: "priv/static/cache_manifest.json"
10 |
11 | # Do not print debug messages in production
12 | config :logger, level: :info
13 |
14 | # Runtime production configuration, including reading
15 | # of environment variables, is done on config/runtime.exs.
16 |
--------------------------------------------------------------------------------
/recognizer/config/runtime.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # config/runtime.exs is executed for all environments, including
4 | # during releases. It is executed after compilation and before the
5 | # system starts, so it is typically used to load production configuration
6 | # and secrets from environment variables or elsewhere. Do not define
7 | # any compile-time configuration in here, as it won't be applied.
8 | # The block below contains prod specific runtime configuration.
9 |
10 | # ## Using releases
11 | #
12 | # If you use `mix release`, you need to explicitly enable the server
13 | # by passing the PHX_SERVER=true when you start it:
14 | #
15 | # PHX_SERVER=true bin/recognizer start
16 | #
17 | # Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
18 | # script that automatically sets the env var above.
19 |
20 | read_ice_port_range! = fn ->
21 | case System.get_env("ICE_PORT_RANGE") do
22 | nil ->
23 | [0]
24 |
25 | raw_port_range ->
26 | case String.split(raw_port_range, "-", parts: 2) do
27 | [from, to] -> String.to_integer(from)..String.to_integer(to)
28 | _other -> raise "ICE_PORT_RANGE has to be in form of FROM-TO, passed: #{raw_port_range}"
29 | end
30 | end
31 | end
32 |
33 | if System.get_env("PHX_SERVER") do
34 | config :recognizer, RecognizerWeb.Endpoint, server: true
35 | end
36 |
37 | config :recognizer, ice_port_range: read_ice_port_range!.()
38 |
39 | if config_env() == :prod do
40 | # The secret key base is used to sign/encrypt cookies and other secrets.
41 | # A default value is used in config/dev.exs and config/test.exs but you
42 | # want to use a different value for prod and you most likely don't want
43 | # to check this value into version control, so we use an environment
44 | # variable instead.
45 | secret_key_base =
46 | System.get_env("SECRET_KEY_BASE") ||
47 | raise """
48 | environment variable SECRET_KEY_BASE is missing.
49 | You can generate one by calling: mix phx.gen.secret
50 | """
51 |
52 | host = System.get_env("PHX_HOST") || "example.com"
53 | port = String.to_integer(System.get_env("PORT") || "4000")
54 |
55 | config :recognizer, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
56 |
57 | config :recognizer, RecognizerWeb.Endpoint,
58 | url: [host: host, port: 443, scheme: "https"],
59 | http: [
60 | # Enable IPv6 and bind on all interfaces.
61 | # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
62 | # See the documentation on https://hexdocs.pm/plug_cowboy/Plug.Cowboy.html
63 | # for details about using IPv6 vs IPv4 and loopback vs public addresses.
64 | ip: {0, 0, 0, 0, 0, 0, 0, 0},
65 | port: port
66 | ],
67 | secret_key_base: secret_key_base
68 |
69 | admin_username =
70 | System.get_env("ADMIN_USERNAME") || raise "Environment variable ADMIN_USERNAME is missing."
71 |
72 | admin_password =
73 | System.get_env("ADMIN_PASSWORD") || raise "Environment variable ADMIN_PASSWORD is missing."
74 |
75 | config :recognizer,
76 | admin_username: admin_username,
77 | admin_password: admin_password
78 |
79 | # ## SSL Support
80 | #
81 | # To get SSL working, you will need to add the `https` key
82 | # to your endpoint configuration:
83 | #
84 | # config :recognizer, RecognizerWeb.Endpoint,
85 | # https: [
86 | # ...,
87 | # port: 443,
88 | # cipher_suite: :strong,
89 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
90 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
91 | # ]
92 | #
93 | # The `cipher_suite` is set to `:strong` to support only the
94 | # latest and more secure SSL ciphers. This means old browsers
95 | # and clients may not be supported. You can set it to
96 | # `:compatible` for wider support.
97 | #
98 | # `:keyfile` and `:certfile` expect an absolute path to the key
99 | # and cert in disk or a relative path inside priv, for example
100 | # "priv/ssl/server.key". For all supported SSL configuration
101 | # options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
102 | #
103 | # We also recommend setting `force_ssl` in your endpoint, ensuring
104 | # no data is ever sent via http, always redirecting to https:
105 | #
106 | # config :recognizer, RecognizerWeb.Endpoint,
107 | # force_ssl: [hsts: true]
108 | #
109 | # Check `Plug.SSL` for all available options in `force_ssl`.
110 | end
111 |
--------------------------------------------------------------------------------
/recognizer/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :recognizer, RecognizerWeb.Endpoint,
6 | http: [ip: {127, 0, 0, 1}, port: 4002],
7 | secret_key_base: "+3zS/OtReHyGngv8VDR/RTkLA5k4+MJMAGZa4PAQYMedOzfT9h/HkwEbXtlyw/g3",
8 | server: false
9 |
10 | # Print only warnings and errors during test
11 | config :logger, level: :warning
12 |
13 | # Initialize plugs at runtime for faster test compilation
14 | config :phoenix, :plug_init_mode, :runtime
15 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer.ex:
--------------------------------------------------------------------------------
1 | defmodule Recognizer do
2 | @moduledoc """
3 | Recognizer keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer/application.ex:
--------------------------------------------------------------------------------
1 | defmodule Recognizer.Application do
2 | # See https://hexdocs.pm/elixir/Application.html
3 | # for more information on OTP Applications
4 | @moduledoc false
5 | alias Recognizer.Lobby
6 |
7 | use Application
8 |
9 | @max_rooms Application.compile_env!(:recognizer, :max_rooms)
10 | @version Mix.Project.config()[:version]
11 |
12 | @spec version() :: String.t()
13 | def version(), do: @version
14 |
15 | @impl true
16 | def start(_type, _args) do
17 | children = [
18 | RecognizerWeb.Telemetry,
19 | {DNSCluster, query: Application.get_env(:recognizer, :dns_cluster_query) || :ignore},
20 | {Phoenix.PubSub, name: Recognizer.PubSub},
21 | # Start a worker by calling: Recognizer.Worker.start_link(arg)
22 | # {Recognizer.Worker, arg},
23 | # Start to serve requests, typically the last entry
24 | RecognizerWeb.Endpoint,
25 | {Registry, keys: :unique, name: Recognizer.RoomRegistry},
26 | {DynamicSupervisor, name: Recognizer.RoomSupervisor, strategy: :one_for_one},
27 | {Nx.Serving,
28 | serving: create_video_serving(),
29 | name: Recognizer.VideoServing,
30 | batch_size: 4,
31 | batch_timeout: 100},
32 | {Lobby, @max_rooms}
33 | ]
34 |
35 | # See https://hexdocs.pm/elixir/Supervisor.html
36 | # for other strategies and supported options
37 | opts = [strategy: :one_for_one, name: Recognizer.Supervisor]
38 | Supervisor.start_link(children, opts)
39 | end
40 |
41 | # Tell Phoenix to update the endpoint configuration
42 | # whenever the application is updated.
43 | @impl true
44 | def config_change(changed, _new, removed) do
45 | RecognizerWeb.Endpoint.config_change(changed, removed)
46 | :ok
47 | end
48 |
49 | defp create_video_serving() do
50 | {:ok, model} = Bumblebee.load_model({:hf, "microsoft/resnet-50"})
51 | {:ok, featurizer} = Bumblebee.load_featurizer({:hf, "microsoft/resnet-50"})
52 |
53 | Bumblebee.Vision.image_classification(model, featurizer,
54 | top_k: 1,
55 | compile: [batch_size: 4],
56 | defn_options: [compiler: EXLA]
57 | )
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer/lobby.ex:
--------------------------------------------------------------------------------
1 | defmodule Recognizer.Lobby do
2 | @moduledoc false
3 |
4 | use GenServer
5 |
6 | require Logger
7 |
8 | alias Recognizer.Room
9 |
10 | def start_link(max_rooms) do
11 | GenServer.start_link(__MODULE__, max_rooms, name: __MODULE__)
12 | end
13 |
14 | def get_room() do
15 | GenServer.call(__MODULE__, :get_room)
16 | end
17 |
18 | @impl true
19 | def init(max_rooms) do
20 | {:ok, %{queue: :queue.new(), rooms: MapSet.new(), max_rooms: max_rooms}}
21 | end
22 |
23 | @impl true
24 | def handle_call(:get_room, {from, _tag}, state) do
25 | _ref = Process.monitor(from)
26 |
27 | if MapSet.size(state.rooms) >= state.max_rooms do
28 | queue = :queue.in(from, state.queue)
29 | state = %{state | queue: queue}
30 | position = :queue.len(queue)
31 | {:reply, {:error, :max_rooms, position}, state}
32 | else
33 | {id, state} = create_room(state)
34 | {:reply, {:ok, id}, state}
35 | end
36 | end
37 |
38 | @impl true
39 | def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
40 | cond do
41 | MapSet.member?(state.rooms, pid) ->
42 | rooms = MapSet.delete(state.rooms, pid)
43 | state = %{state | rooms: rooms}
44 |
45 | case :queue.out(state.queue) do
46 | {{:value, pid}, queue} ->
47 | state = %{state | queue: queue}
48 | {id, state} = create_room(state)
49 | send(pid, {:room, id})
50 | send_positions(state)
51 | {:noreply, state}
52 |
53 | {:empty, queue} ->
54 | state = %{state | queue: queue}
55 | {:noreply, state}
56 | end
57 |
58 | :queue.member(pid, state.queue) == true ->
59 | queue = :queue.delete(pid, state.queue)
60 | state = %{state | queue: queue}
61 | send_positions(state)
62 | {:noreply, state}
63 |
64 | true ->
65 | Logger.warning("Unexpected DOWN message from pid: #{inspect(pid)}")
66 | {:noreply, state}
67 | end
68 | end
69 |
70 | defp create_room(state) do
71 | Logger.info("Creating a new room")
72 | <
> = :crypto.strong_rand_bytes(12)
73 | {:ok, pid} = DynamicSupervisor.start_child(Recognizer.RoomSupervisor, {Room, id})
74 | Process.monitor(pid)
75 | rooms = MapSet.put(state.rooms, pid)
76 | state = %{state | rooms: rooms}
77 | {id, state}
78 | end
79 |
80 | defp send_positions(state) do
81 | :queue.to_list(state.queue)
82 | |> Stream.with_index()
83 | |> Enum.each(fn {pid, idx} -> send(pid, {:position, idx + 1}) end)
84 | end
85 | end
86 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, components, channels, and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use RecognizerWeb, :controller
9 | use RecognizerWeb, :html
10 |
11 | The definitions below will be executed for every controller,
12 | component, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define additional modules and import
17 | those modules here.
18 | """
19 |
20 | def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
21 |
22 | def router do
23 | quote do
24 | use Phoenix.Router, helpers: false
25 |
26 | # Import common connection and controller functions to use in pipelines
27 | import Plug.Conn
28 | import Phoenix.Controller
29 | import Phoenix.LiveView.Router
30 | end
31 | end
32 |
33 | def channel do
34 | quote do
35 | use Phoenix.Channel
36 | end
37 | end
38 |
39 | def controller do
40 | quote do
41 | use Phoenix.Controller,
42 | formats: [:html, :json],
43 | layouts: [html: RecognizerWeb.Layouts]
44 |
45 | import Plug.Conn
46 |
47 | unquote(verified_routes())
48 | end
49 | end
50 |
51 | def live_view do
52 | quote do
53 | use Phoenix.LiveView,
54 | layout: {RecognizerWeb.Layouts, :app}
55 |
56 | unquote(html_helpers())
57 | end
58 | end
59 |
60 | def live_component do
61 | quote do
62 | use Phoenix.LiveComponent
63 |
64 | unquote(html_helpers())
65 | end
66 | end
67 |
68 | def html do
69 | quote do
70 | use Phoenix.Component
71 |
72 | # Import convenience functions from controllers
73 | import Phoenix.Controller,
74 | only: [get_csrf_token: 0, view_module: 1, view_template: 1]
75 |
76 | # Include general helpers for rendering HTML
77 | unquote(html_helpers())
78 | end
79 | end
80 |
81 | defp html_helpers do
82 | quote do
83 | # HTML escaping functionality
84 | import Phoenix.HTML
85 | # Core UI components and translation
86 | import RecognizerWeb.CoreComponents
87 |
88 | # Shortcut for generating JS commands
89 | alias Phoenix.LiveView.JS
90 |
91 | # Routes generation with the ~p sigil
92 | unquote(verified_routes())
93 | end
94 | end
95 |
96 | def verified_routes do
97 | quote do
98 | use Phoenix.VerifiedRoutes,
99 | endpoint: RecognizerWeb.Endpoint,
100 | router: RecognizerWeb.Router,
101 | statics: RecognizerWeb.static_paths()
102 | end
103 | end
104 |
105 | @doc """
106 | When used, dispatch to the appropriate controller/view/etc.
107 | """
108 | defmacro __using__(which) when is_atom(which) do
109 | apply(__MODULE__, which, [])
110 | end
111 | end
112 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/channels/room_channel.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.RoomChannel do
2 | @moduledoc false
3 |
4 | use Phoenix.Channel, restart: :temporary
5 |
6 | require Logger
7 |
8 | alias Recognizer.Room
9 |
10 | def join("room:" <> id, _message, socket) do
11 | id = String.to_integer(id)
12 | :ok = Room.connect(id, self())
13 | {:ok, assign(socket, :room_id, id)}
14 | end
15 |
16 | def handle_in("signaling", msg, socket) do
17 | :ok = Room.receive_signaling_msg(socket.assigns.room_id, msg)
18 | {:noreply, socket}
19 | end
20 |
21 | def handle_info({:signaling, msg}, socket) do
22 | push(socket, "signaling", msg)
23 | {:noreply, socket}
24 | end
25 |
26 | def handle_info({:img_reco, msg}, socket) do
27 | push(socket, "imgReco", msg)
28 | {:noreply, socket}
29 | end
30 |
31 | def handle_info({:session_time, session_time}, socket) do
32 | push(socket, "sessionTime", %{time: session_time})
33 | {:noreply, socket}
34 | end
35 |
36 | def handle_info(:session_expired, socket) do
37 | {:stop, {:shutdown, :session_expired}, socket}
38 | end
39 |
40 | def terminate(reason, _socket) do
41 | Logger.info("Stopping Phoenix chnannel, reason: #{inspect(reason)}.")
42 | end
43 | end
44 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/components/layouts.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.Layouts do
2 | use RecognizerWeb, :html
3 |
4 | embed_templates "layouts/*"
5 | end
6 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/components/layouts/app.html.heex:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 | <.flash_group flash={@flash} />
26 | <%= @inner_content %>
27 |
28 |
29 |
32 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/components/layouts/root.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | <.live_title suffix=" · Recognizer">
8 | <%= assigns[:page_title] %>
9 |
10 |
11 |
13 |
14 |
15 | <%= @inner_content %>
16 |
17 |
18 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/controllers/error_html.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.ErrorHTML do
2 | use RecognizerWeb, :html
3 |
4 | # If you want to customize your error pages,
5 | # uncomment the embed_templates/1 call below
6 | # and add pages to the error directory:
7 | #
8 | # * lib/recognizer_web/controllers/error_html/404.html.heex
9 | # * lib/recognizer_web/controllers/error_html/500.html.heex
10 | #
11 | # embed_templates "error_html/*"
12 |
13 | # The default is to render a plain text page based on
14 | # the template name. For example, "404.html" becomes
15 | # "Not Found".
16 | def render(template, _assigns) do
17 | Phoenix.Controller.status_message_from_template(template)
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/controllers/error_json.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.ErrorJSON do
2 | # If you want to customize a particular status code,
3 | # you may add your own clauses, such as:
4 | #
5 | # def render("500.json", _assigns) do
6 | # %{errors: %{detail: "Internal Server Error"}}
7 | # end
8 |
9 | # By default, Phoenix returns the status message from
10 | # the template name. For example, "404.json" becomes
11 | # "Not Found".
12 | def render(template, _assigns) do
13 | %{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/controllers/room_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.RoomController do
2 | use RecognizerWeb, :controller
3 |
4 | def room(conn, _params) do
5 | render(conn, :room, page_title: "Room")
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/controllers/room_html.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.RoomHTML do
2 | use RecognizerWeb, :html
3 |
4 | embed_templates "room_html/*"
5 | end
6 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/controllers/room_html/room.html.heex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
Remaining time
7 |
11 |
12 |
13 |
14 |
15 |
Score
16 |
20 |
21 |
22 |
23 |
24 |
25 |
Prediction
26 |
30 |
31 |
32 |
33 |
39 |
40 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :recognizer
3 |
4 | # The session will be stored in the cookie and signed,
5 | # this means its contents can be read but not tampered with.
6 | # Set :encryption_salt if you would also like to encrypt it.
7 | @session_options [
8 | store: :cookie,
9 | key: "_recognizer_key",
10 | signing_salt: "nEL+BRvh",
11 | same_site: "Lax"
12 | ]
13 |
14 | socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]]
15 |
16 | socket "/socket", RecognizerWeb.RoomSocket,
17 | websocket: true,
18 | longpoll: false
19 |
20 | # Serve at "/" the static files from "priv/static" directory.
21 | #
22 | # You should set gzip to true if you are running phx.digest
23 | # when deploying your static files in production.
24 | plug Plug.Static,
25 | at: "/",
26 | from: :recognizer,
27 | gzip: false,
28 | only: RecognizerWeb.static_paths()
29 |
30 | # Code reloading can be explicitly enabled under the
31 | # :code_reloader configuration of your endpoint.
32 | if code_reloading? do
33 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
34 | plug Phoenix.LiveReloader
35 | plug Phoenix.CodeReloader
36 | end
37 |
38 | plug Phoenix.LiveDashboard.RequestLogger,
39 | param_key: "request_logger",
40 | cookie_key: "request_logger"
41 |
42 | plug Plug.RequestId
43 | plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
44 |
45 | plug Plug.Parsers,
46 | parsers: [:urlencoded, :multipart, :json],
47 | pass: ["*/*"],
48 | json_decoder: Phoenix.json_library()
49 |
50 | plug Plug.MethodOverride
51 | plug Plug.Head
52 | plug Plug.Session, @session_options
53 | plug RecognizerWeb.Router
54 | end
55 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/live/lobby_live.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.LobbyLive do
2 | use Phoenix.LiveView,
3 | container: {:div, class: "contents"},
4 | layout: {RecognizerWeb.Layouts, :app}
5 |
6 | alias Recognizer.Lobby
7 |
8 | @session_time 200
9 | @eta_update_interval_ms 1000
10 |
11 | def render(assigns) do
12 | ~H"""
13 |
14 |
15 |
16 | Whoops!
Looks like our servers are experiencing pretty high load!
17 | We put you in the queue and will redirect you once it's your turn.
18 | You are <%= @position %> in the queue.
19 | ETA: <%= @eta %> seconds.
20 |
21 |
22 |
23 | """
24 | end
25 |
26 | def mount(_params, _session, socket) do
27 | case Lobby.get_room() do
28 | {:ok, room_id} ->
29 | {:ok, push_navigate(socket, to: "/room/#{room_id}")}
30 |
31 | {:error, :max_rooms, position} ->
32 | Process.send_after(self(), :update_eta, @eta_update_interval_ms)
33 | socket = assign(socket, page_title: "Lobby")
34 | socket = assign(socket, position: position)
35 | socket = assign(socket, eta: position * @session_time)
36 | socket = assign(socket, last_check: System.monotonic_time(:millisecond))
37 | {:ok, socket}
38 | end
39 | end
40 |
41 | def handle_info({:position, position}, socket) do
42 | socket = assign(socket, position: position)
43 | socket = assign(socket, eta: position * @session_time)
44 | {:noreply, socket}
45 | end
46 |
47 | def handle_info({:room, room_id}, socket) do
48 | {:noreply, push_navigate(socket, to: "/room/#{room_id}")}
49 | end
50 |
51 | def handle_info(:update_eta, socket) do
52 | Process.send_after(self(), :update_eta, @eta_update_interval_ms)
53 | now = System.monotonic_time(:millisecond)
54 | elapsed = floor((now - socket.assigns.last_check) / 1000)
55 | eta = max(0, socket.assigns.eta - elapsed)
56 | socket = assign(socket, eta: eta)
57 | socket = assign(socket, last_check: now)
58 | {:noreply, socket}
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/live/recognizer_live.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.RecognizerLive do
2 | use Phoenix.LiveView,
3 | container: {:div, class: "contents"},
4 | layout: {RecognizerWeb.Layouts, :app}
5 |
6 | def render(assigns) do
7 | ~H"""
8 |
15 | """
16 | end
17 |
18 | def mount(_params, _session, socket) do
19 | socket = assign(socket, :page_title, "Home")
20 | {:ok, socket}
21 | end
22 |
23 | def handle_event("start", _params, socket) do
24 | {:noreply, push_navigate(socket, to: "/lobby")}
25 | end
26 | end
27 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/room_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.RoomSocket do
2 | use Phoenix.Socket
3 |
4 | channel "room:*", RecognizerWeb.RoomChannel
5 |
6 | def connect(_params, socket, _connect_info) do
7 | {:ok, socket}
8 | end
9 |
10 | def id(_socket), do: nil
11 | end
12 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.Router do
2 | use RecognizerWeb, :router
3 |
4 | import Phoenix.LiveDashboard.Router
5 |
6 | pipeline :browser do
7 | plug :accepts, ["html"]
8 | plug :fetch_session
9 | plug :fetch_live_flash
10 | plug :put_root_layout, html: {RecognizerWeb.Layouts, :root}
11 | plug :protect_from_forgery
12 | plug :put_secure_browser_headers
13 | end
14 |
15 | pipeline :auth do
16 | plug :admin_auth
17 | end
18 |
19 | scope "/", RecognizerWeb do
20 | pipe_through :browser
21 |
22 | live "/", RecognizerLive
23 | live "/lobby", LobbyLive
24 | get "/room/:room_id", RoomController, :room
25 | end
26 |
27 | scope "/admin", RecognizerWeb do
28 | pipe_through :auth
29 | pipe_through :browser
30 |
31 | live_dashboard "/dashboard",
32 | metrics: RecognizerWeb.Telemetry,
33 | additional_pages: [exwebrtc: ExWebRTCDashboard]
34 | end
35 |
36 | defp admin_auth(conn, _opts) do
37 | username = Application.fetch_env!(:recognizer, :admin_username)
38 | password = Application.fetch_env!(:recognizer, :admin_password)
39 | Plug.BasicAuth.basic_auth(conn, username: username, password: password)
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/recognizer/lib/recognizer_web/telemetry.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.Telemetry do
2 | use Supervisor
3 | import Telemetry.Metrics
4 |
5 | def start_link(arg) do
6 | Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
7 | end
8 |
9 | @impl true
10 | def init(_arg) do
11 | children = [
12 | # Telemetry poller will execute the given period measurements
13 | # every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
14 | {:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
15 | # Add reporters as children of your supervision tree.
16 | # {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
17 | ]
18 |
19 | Supervisor.init(children, strategy: :one_for_one)
20 | end
21 |
22 | def metrics do
23 | [
24 | # Phoenix Metrics
25 | summary("phoenix.endpoint.start.system_time",
26 | unit: {:native, :millisecond}
27 | ),
28 | summary("phoenix.endpoint.stop.duration",
29 | unit: {:native, :millisecond}
30 | ),
31 | summary("phoenix.router_dispatch.start.system_time",
32 | tags: [:route],
33 | unit: {:native, :millisecond}
34 | ),
35 | summary("phoenix.router_dispatch.exception.duration",
36 | tags: [:route],
37 | unit: {:native, :millisecond}
38 | ),
39 | summary("phoenix.router_dispatch.stop.duration",
40 | tags: [:route],
41 | unit: {:native, :millisecond}
42 | ),
43 | summary("phoenix.socket_connected.duration",
44 | unit: {:native, :millisecond}
45 | ),
46 | summary("phoenix.channel_joined.duration",
47 | unit: {:native, :millisecond}
48 | ),
49 | summary("phoenix.channel_handled_in.duration",
50 | tags: [:event],
51 | unit: {:native, :millisecond}
52 | ),
53 |
54 | # VM Metrics
55 | summary("vm.memory.total", unit: {:byte, :kilobyte}),
56 | summary("vm.total_run_queue_lengths.total"),
57 | summary("vm.total_run_queue_lengths.cpu"),
58 | summary("vm.total_run_queue_lengths.io")
59 | ]
60 | end
61 |
62 | defp periodic_measurements do
63 | [
64 | # A module, function and arguments to be invoked periodically.
65 | # This function must call :telemetry.execute/3 and a metric must be added above.
66 | # {RecognizerWeb, :count_users, []}
67 | ]
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/recognizer/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Recognizer.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :recognizer,
7 | version: "0.6.0",
8 | elixir: "~> 1.14",
9 | elixirc_paths: elixirc_paths(Mix.env()),
10 | start_permanent: Mix.env() == :prod,
11 | aliases: aliases(),
12 | deps: deps(),
13 |
14 | # dialyzer
15 | dialyzer: [
16 | plt_local_path: "_dialyzer",
17 | plt_core_path: "_dialyzer"
18 | ]
19 | ]
20 | end
21 |
22 | # Configuration for the OTP application.
23 | #
24 | # Type `mix help compile.app` for more information.
25 | def application do
26 | [
27 | mod: {Recognizer.Application, []},
28 | extra_applications: [:logger, :runtime_tools]
29 | ]
30 | end
31 |
32 | # Specifies which paths to compile per environment.
33 | defp elixirc_paths(:test), do: ["lib", "test/support"]
34 | defp elixirc_paths(_), do: ["lib"]
35 |
36 | # Specifies your project dependencies.
37 | #
38 | # Type `mix help deps` for examples and options.
39 | defp deps do
40 | [
41 | {:phoenix, "~> 1.7.10"},
42 | {:phoenix_html, "~> 3.3"},
43 | {:phoenix_live_reload, "~> 1.2", only: :dev},
44 | {:phoenix_live_view, "~> 0.20.1"},
45 | {:floki, ">= 0.30.0", only: :test},
46 | {:phoenix_live_dashboard, "~> 0.8.2"},
47 | {:esbuild, "~> 0.8", runtime: Mix.env() == :dev},
48 | {:tailwind, "~> 0.2.0", runtime: Mix.env() == :dev},
49 | {:heroicons,
50 | github: "tailwindlabs/heroicons",
51 | tag: "v2.1.1",
52 | sparse: "optimized",
53 | app: false,
54 | compile: false,
55 | depth: 1},
56 | {:telemetry_metrics, "~> 0.6"},
57 | {:telemetry_poller, "~> 1.0"},
58 | {:jason, "~> 1.2"},
59 | {:dns_cluster, "~> 0.1.1"},
60 | {:plug_cowboy, "~> 2.5"},
61 | {:ex_webrtc, "~> 0.8.0"},
62 | {:ex_webrtc_dashboard, "~> 0.8.0"},
63 | {:xav, "~> 0.8.0"},
64 | {:bumblebee, "~> 0.5.3"},
65 | {:exla, "~> 0.7.1"},
66 |
67 | # Dialyzer and credo
68 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false},
69 | {:credo, ">= 0.0.0", only: :dev, runtime: false}
70 | ]
71 | end
72 |
73 | # Aliases are shortcuts or tasks specific to the current project.
74 | # For example, to install project dependencies and perform other setup tasks, run:
75 | #
76 | # $ mix setup
77 | #
78 | # See the documentation for `Mix` for more info on aliases.
79 | defp aliases do
80 | [
81 | setup: ["deps.get", "assets.setup", "assets.build"],
82 | "assets.setup": ["tailwind.install --if-missing", "esbuild.install --if-missing"],
83 | "assets.build": ["tailwind default", "esbuild default"],
84 | "assets.deploy": ["tailwind default --minify", "esbuild default --minify", "phx.digest"],
85 | "assets.format": &lint_and_format_assets/1,
86 | "assets.check": &check_assets/1
87 | ]
88 | end
89 |
90 | defp lint_and_format_assets(_args) do
91 | with {_, 0} <- execute_npm_command(["ci"]),
92 | {_, 0} <- execute_npm_command(["run", "lint"]),
93 | {_, 0} <- execute_npm_command(["run", "format"]) do
94 | :ok
95 | else
96 | {cmd, rc} ->
97 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}")
98 | exit({:shutdown, rc})
99 | end
100 | end
101 |
102 | defp check_assets(_args) do
103 | with {_, 0} <- execute_npm_command(["ci"]),
104 | {_, 0} <- execute_npm_command(["run", "check"]) do
105 | :ok
106 | else
107 | {cmd, rc} ->
108 | Mix.shell().error("npm command `#{Enum.join(cmd, " ")}` failed with code #{rc}")
109 | exit({:shutdown, rc})
110 | end
111 | end
112 |
113 | defp execute_npm_command(command) do
114 | {_stream, rc} = System.cmd("npm", ["--prefix=assets"] ++ command, into: IO.stream())
115 | {command, rc}
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/recognizer/priv/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/elixir-webrtc/apps/4502fee12b35b1e9e0b64d613dae70b63a07e78d/recognizer/priv/static/favicon.ico
--------------------------------------------------------------------------------
/recognizer/priv/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/recognizer/rel/overlays/bin/server:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | set -eu
3 |
4 | cd -P -- "$(dirname -- "$0")"
5 | PHX_SERVER=true exec ./recognizer start
6 |
--------------------------------------------------------------------------------
/recognizer/rel/overlays/bin/server.bat:
--------------------------------------------------------------------------------
1 | set PHX_SERVER=true
2 | call "%~dp0\recognizer" start
3 |
--------------------------------------------------------------------------------
/recognizer/test/recognizer_web/controllers/error_html_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.ErrorHTMLTest do
2 | use RecognizerWeb.ConnCase, async: true
3 |
4 | # Bring render_to_string/4 for testing custom views
5 | import Phoenix.Template
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(RecognizerWeb.ErrorHTML, "404", "html", []) == "Not Found"
9 | end
10 |
11 | test "renders 500.html" do
12 | assert render_to_string(RecognizerWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/recognizer/test/recognizer_web/controllers/error_json_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.ErrorJSONTest do
2 | use RecognizerWeb.ConnCase, async: true
3 |
4 | test "renders 404" do
5 | assert RecognizerWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
6 | end
7 |
8 | test "renders 500" do
9 | assert RecognizerWeb.ErrorJSON.render("500.json", %{}) ==
10 | %{errors: %{detail: "Internal Server Error"}}
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/recognizer/test/recognizer_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.PageControllerTest do
2 | use RecognizerWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get(conn, ~p"/")
6 | assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/recognizer/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule RecognizerWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common data structures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | we enable the SQL sandbox, so changes done to the database
12 | are reverted at the end of every test. If you are using
13 | PostgreSQL, you can even run database tests asynchronously
14 | by setting `use RecognizerWeb.ConnCase, async: true`, although
15 | this option is not recommended for other databases.
16 | """
17 |
18 | use ExUnit.CaseTemplate
19 |
20 | using do
21 | quote do
22 | # The default endpoint for testing
23 | @endpoint RecognizerWeb.Endpoint
24 |
25 | use RecognizerWeb, :verified_routes
26 |
27 | # Import conveniences for testing with connections
28 | import Plug.Conn
29 | import Phoenix.ConnTest
30 | import RecognizerWeb.ConnCase
31 | end
32 | end
33 |
34 | setup _tags do
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 | end
38 |
--------------------------------------------------------------------------------
/recognizer/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------