#
# Created by Boyd Multerer on 2021-04-17.
# Copyright © 2021 Kry10 Limited. All rights reserved.
#
defmodule Scenic.Assets.Static do
@moduledoc """
Manages static assets, which are resources such as fonts or images (jpg or png) that
ship with your application and do not change over time.
These assets live as seperate files and are hashed so that they are rejected if they
change in any way after you compile your application. They are cacheable by the
relay server if you remote your Scenic UI.
In previous versions of Scenic, static assets were rather complicated to set up
and maintain. Starting with v0.11, Scenic has an assets build pipeline that manages
the static assets library for you.
### Required Configuration
Setting up the static asset pipeline requites several inputs that need to be maintained.
* __Assets Directory__: Typically `/assets` in your main app source directory. This is the
folder that holds your raw asset files.
* __Assets Module__: A module in your application that builds and holds the asset library.
* __Assets Config__: Configuration scripts in your application that indicates where the
assets directory is and your assets module.
#### Assets Directory
The assets directory typically is typically called `/assets` and lives at the root of
your application source directory. This can be changed in the config options.
Example:
```
my_app_src
assets
fonts
roboto.ttf
custom_font.ttf
images
parrot.jpg
my_logo.png
config
lib
etc...
```
Once the rest of the configuration is complete, adding a new font is as simple as dropping
the *.ttf file into the /assets/fonts directory and compiling your assets module. Similar
is true for images.
#### Assets Library
When your application is running, there needs to be a module that contains the built asset
library referring to your static assets. This library holds things like the hash of the
contents of each asset, and it's parsed metadata.
You must create this module and compile it with your application. The following example
is what this module should look like. Replace `MyApplication` and `:my_application` with
the actual name of your application.
```elixir
defmodule MyApplication.Assets do
use Scenic.Assets.Static,
otp_app: :my_application,
sources: [
"assets",
{:scenic, "deps/scenic/assets"}
],
alias: [
parrot: "images/parrot.jpg"
]
end
```
Notice that there are several configuration sections in your assets module. Sources is the list
of folders to look in to find assets. For example, if you take a dependency on a package that
contains assets, you will need to add it's assets folder here. If can omit the sources section
if you only use a single assets folder and scenic's default fonts. In other words, the sources
configuration shown above is also the default.
The `:alias` list creates shortcuts that refer to the files in the assets library. This is useful
if you think an asset id may change during development but want a constant way to refer to it
in your code. In the above example, the atom :parrot is mapped to the file `images/parrot.jpb`
and are interchangeable with each other in a graph.
In this example, the two rect fills are identical as the `:parrot` alias was created
in the configuration script.
```elixir
Graph.build()
|> rect({100, 50}, fill: {:image, "images/parrot.jpg"})
|> rect({100, 50}, fill: {:image, :parrot})
```
The fonts `fonts/roboto.ttf` and `fonts/roboto_mono.ttf` are considered the default
fonts for Scenic and are automatically aliased to `:roboto` and `:roboto_mono`.
It is expected that you will include those two fonts in your `/assets/fonts` directory.
IMPORTANT NOTE: When you add a new asset to the assets directory, you may need to force this
module to recompile for them to be usable. Adding or removing a return at the end should do
the trick. In the future, there will be a file system watcher (much like Phoenix has) that
will do this automatically. Until then, it is pretty easy to do manually.
#### Assets Configuration
The final piece is some configuration that connects scenic and your assets module
togther. Put this in your application's `config.exs` file.
```elixir
config :scenic, :assets,
module: MyApplication.Assets
```
### Troubleshooting
If you have added an asset to your assets directory and you think it should be in
your library, but it isn't, or you can't compile a scene because the asset can't
be found, then start troubleshooting with the following steps.
1) Force your assets module to rebuild. Touch it in some way such as adding or removing a
carriage return at the end, then compile again.
2) Check that you are using the correct id for the asset in your graph.
3) If you are using an alias, check it's spelling and its assignment in the config script.
4) Confirm that the asset itself has valid contents, whether it is a font (.ttf)
or an image (.jpg or .jpeg or .png)
That usually does it.
### Under the Covers
When your assets module is compiled, several steps are executed by `Scenic.Assets.Static`
1) The files in your assets directory are parsed for validity and metadata.
Valid files move on to the next step
2) The valid assets files are hashed to create a cryptographic signature that is used
later when the files are loaded to confirm that they are unchanged.
3) The asset files are copied into the `/priv/static` directory, which is where they
are actually loaded from at run time. The name of the file in this directory is
a `Base.url_encode64/2` version of the hash of the file's contents.
4) A map is created, which is the actual asset library used at runtime. This map
has the original file name as keys and holds the hashes, and parsed metadata
as the contents. This map is stored as a literal object in your assets module
and is the reason it needs to be compiled when you add a new asset.
If you are curious and want to see the library yourself, you can query the
`MyApplication.Assets.library/0` function, which is added at compile time. Alternately,
the function `Scenic.Assets.Static.library/0` should return the same library.
### Future Work
There are two pieces of work to the static assets pipeline that are planned for the future.
First is a file system watcher that automatically flags your assets module to be recompiled
when the contents of the assets directory changes. This would work in a similar way to the
file system watcher used by Phoenix.
The second, larger, piece of work is to include optional transform scripts/code when
your assets module is compiled. This would let you do things like putting a very
high resolution image in the sources folder and down-scaling at compile time as
appropriate for the target device you are compiling for. In the meantime, just put
in the assets you want to use directly.
"""
require Logger
alias Scenic.Assets.Static
# import IEx
# https://hexdocs.pm/mix/1.12/Mix.Tasks.Compile.Elixir.html
# ===========================================================================
# the using macro for scenes adopting this behavior
defmacro __using__(using_opts \\ []) do
quote do
# this section of code is to "watch" the assets folder to look for changes
# it does this by marking the external asset files as objects that the
# module depends on
@sources Keyword.get(unquote(using_opts), :sources, ["assets"])
@paths Enum.reduce(@sources, [], fn
source, acc when is_bitstring(source) ->
[Path.wildcard("#{source}/**/*.{jpg,jpeg,png,ttf}") | acc]
_, acc ->
acc
end)
|> List.flatten()
|> Enum.uniq()
@paths_hash :erlang.md5(@paths)
for path <- @paths do
@external_resource path
end
# called every time compile is run.
# returns a boolean indicating if this module should
# be recompiled
@doc false
def __mix_recompile__?() do
Scenic.Assets.Static.compile_assets?(
library(),
@paths_hash,
unquote(using_opts)
)
end
@library Scenic.Assets.Static.build!(__MODULE__, unquote(using_opts))
def library(), do: @library
# quote
end
# defmacro
end
# import IEx
@type id :: String.t() | atom | {atom, String.t()}
@hash_type :sha3_256
@dst_dir "/priv/__scenic/assets"
@default_src_dir "assets"
@default_aliases [
roboto: {:scenic, "fonts/roboto.ttf"},
roboto_mono: {:scenic, "fonts/roboto_mono.ttf"}
]
@parsers [
Scenic.Assets.Static.Image,
Scenic.Assets.Static.Font
]
@type t :: %Scenic.Assets.Static{
aliases: map,
metas: map,
hash_type: :sha3_256,
module: module,
otp_app: atom,
meta_hash: binary
}
defstruct aliases: %{},
metas: %{},
hash_type: @hash_type,
module: nil,
otp_app: nil,
meta_hash: ""
# ===========================================================================
defmodule Error do
@moduledoc false
defexception message: nil, error: nil, id: nil
end
# --------------------------------------------------------
@doc false
# called during __mix_recompile__?() from the assets module
def compile_assets?(library, paths_hash, opts) do
assets_changed?(paths_hash, opts) || fix_cache?(library, opts)
end
defp assets_changed?(paths_hash, opts) do
hash =
opts
|> Keyword.get(:sources, [@default_src_dir])
|> Enum.reduce([], fn
source, acc when is_bitstring(source) ->
[Path.wildcard("#{source}/**/*.{jpg,jpeg,png,ttf}") | acc]
_, acc ->
acc
end)
|> List.flatten()
|> Enum.uniq()
|> :erlang.md5()
hash != paths_hash
end
defp fix_cache?(library, opts) do
opts[:otp_app]
|> :code.lib_dir()
|> Path.join(dst_dir())
|> File.ls()
|> case do
{:ok, files} -> meta_hash(files) != library.meta_hash
_ -> true
end
end
defp meta_hash(files) when is_list(files) do
files
|> Enum.sort()
|> :erlang.md5()
end
# ===========================================================================
# --------------------------------------------------------
@doc """
Return the configured asset library module.
"""
def module() do
with {:ok, config} <- Application.fetch_env(:scenic, :assets),
{:ok, module} <- Keyword.fetch(config, :module) do
module
else
_ ->
raise """
No assets module is configured.
You need to create an assets modulein your application.
Then connect it to Scenic with some config.
Example assets module that includes an optional alias:
defmodule MyApplication.Assets do
use Scenic.Assets.Static,
otp_app: :my_application,
alias: [
my_parrot: "images/my_parrot.jpg"
]
end
Example configuration script (this goes in your config.exs file):
config :scenic, :assets,
module: MyApplication.Assets
"""
end
end
@doc "Return the compiled asset library."
def library(), do: module().library()
@doc false
def dst_dir(), do: @dst_dir
# --------------------------------------------------------
@doc """
Transform an asset id into the file hash.
If you pass in a valid hash, it is returned unchanged
Example:
```elixir
alias Scenic.Assets.Static
library = Scenic.Assets.Static.library()
{:ok, "VvWQFjblIwTGsvGx866t8MIG2czWyIc8by6Xc88AOns"} = Static.hash( library, :parrot )
{:ok, "VvWQFjblIwTGsvGx866t8MIG2czWyIc8by6Xc88AOns"} = Static.hash( library, "images/parrot.png" )
{:ok, "VvWQFjblIwTGsvGx866t8MIG2czWyIc8by6Xc88AOns"} = Static.hash( library, "VvWQFjblIwTGsvGx866t8MIG2czWyIc8by6Xc88AOns" )
```
"""
@spec to_hash(id :: any) :: {:ok, hash :: any} | :error
def to_hash(id), do: library() |> to_hash(id)
@spec to_hash(library :: t(), id :: any) :: {:ok, hash :: any} | :error
def to_hash(%Static{aliases: aliases, metas: metas}, id) do
case Map.fetch(metas, id) do
{:ok, _} -> {:ok, id}
:error -> Map.fetch(aliases, id)
end
end
# --------------------------------------------------------
@doc """
Fetch the metadata for an asset by id.
Return is in the form of `{:ok, metadata}`
If the hash is not in the library, `:error` is returned.
Example:
```elixir
{:ok, meta} = Scenic.Assets.Static.fetch( :parrot )
```
"""
@spec meta(id :: any) :: {:ok, meta :: any} | :error
def meta(id), do: library() |> meta(id)
@spec meta(library :: t(), id :: any) :: {:ok, meta :: any} | :error
def meta(%Static{metas: metas} = lib, id) do
case to_hash(lib, id) do
{:ok, hash} -> Map.fetch(metas, hash)
err -> err
end
end
# --------------------------------------------------------
@doc """
Load the binary contents of an asset given it's id or hash.
Return is in the form of `{:ok, bin}`
If the asset is not in the library, `{:error, :not_found}` is returned.
The contents of the file will be hashed and compared against the hash found in
the library. If this test fails, `{:error, :hash_failed}` is returned.
If the output file cannot be read, it returns a posix error.
"""
@spec load(id :: any) ::
{:ok, data :: binary}
| {:error, :not_found}
| {:error, :hash_failed}
| {:error, File.posix()}
def load(id), do: library() |> load(id)
@spec load(library :: t(), id :: any) ::
{:ok, data :: binary}
| {:error, :not_found}
| {:error, :hash_failed}
| {:error, File.posix()}
def load(%Static{otp_app: otp_app, hash_type: hash_type} = lib, id) do
dir =
otp_app
|> :code.lib_dir()
|> Path.join(dst_dir())
with {:ok, str_hash} <- to_hash(lib, id),
{:ok, bin_hash} <- Base.url_decode64(str_hash, padding: false),
{:ok, bin} <- File.read(Path.join(dir, str_hash)),
^bin_hash <- :crypto.hash(hash_type, bin) do
{:ok, bin}
else
:error ->
err = {:error, :not_found}
Logger.error("asset: #{inspect(id)} from #{dir}, error: #{inspect(err)}")
err
bin when is_binary(bin) ->
err = :hash_failed
Logger.error("asset: #{inspect(id)} from #{dir}, error: #{inspect(err)}")
{:error, :hash_failed}
err ->
Logger.error("asset: #{inspect(id)} from #{dir}, error: #{inspect(err)}")
err
end
end
# ========================================================
# --------------------------------------------------------
@doc false
def build!(library_module, opts \\ []) when is_atom(library_module) do
# simultaneously calc the dst dir and validate the :otp_app option
dst =
try do
opts[:otp_app]
|> :code.lib_dir()
|> Path.join(Static.dst_dir())
rescue
_ ->
raise """
'use Scenic.Assets.Static' requires a valid :otp_app option.
"""
end
# make sure the destination directory exists (delete and recreate to keep it clean)
File.rm_rf(dst)
File.mkdir_p!(dst)
# start building the library
library = %Static{module: library_module, otp_app: opts[:otp_app]}
# build the file data and metas from the sources
library =
case opts[:sources] do
nil -> [{opts[:otp_app], @default_src_dir}, {:scenic, "deps/scenic/assets"}]
srcs -> srcs
end
|> Enum.reduce(library, &build_from_source(&2, &1, dst))
# add the default aliases
library = Enum.reduce(@default_aliases, library, &add_alias(&2, &1))
# add any additional aliases
library =
case opts[:alias] || opts[:aliases] do
nil -> []
aliases -> aliases
end
|> Enum.reduce(library, &add_alias(&2, &1))
# finally add the meta_hash
meta_hash =
library.metas
|> Enum.map(fn {k, _v} -> k end)
|> meta_hash()
Map.put(library, :meta_hash, meta_hash)
end
# --------------------------------------------------------
defp build_from_source(library, source, dst)
defp build_from_source(%Static{otp_app: app} = lib, src, dst) when is_bitstring(src) do
build_from_source(lib, {app, src}, dst)
end
defp build_from_source(%Static{} = lib, {app, dir}, dst)
when is_atom(app) and is_bitstring(dir) do
# build the library
dir
|> Path.join("**")
|> Path.wildcard()
|> Enum.reduce(lib, fn path, lib ->
case File.dir?(path) do
true -> lib
false -> ingest_file(lib, app, dir, path, dst)
end
end)
end
defp build_from_source(%Static{module: mod}, src, _dst) do
raise """
Invalid :sources list when building assets library #{inspect(mod)}
Received: #{inspect(src)}
Expected a list of sources in the format of
[{otp_app, assets_path}]
"""
end
# --------------------------------------------------------
defp ingest_file(%Static{otp_app: otp_app, hash_type: hash_type} = lib, src_app, dir, path, dst) do
with {:ok, bin} <- File.read(path),
{:ok, meta} <- parse_bin(bin) do
id = Path.relative_to(path, dir)
bin_hash = :crypto.hash(hash_type, bin)
str_hash = Base.url_encode64(bin_hash, padding: false)
Path.join(dst, str_hash) |> File.write!(bin)
# fill in the library entries
lib =
lib
|> assign(:metas, str_hash, meta)
|> assign(:aliases, {src_app, id}, str_hash)
case otp_app == src_app do
true -> assign(lib, :aliases, id, str_hash)
false -> lib
end
else
_ -> lib
end
end
# --------------------------------------------------------
def assign(%Static{metas: metas} = lib, :metas, key, value) do
%{lib | metas: Map.put(metas, key, value)}
end
def assign(%Static{aliases: aliases} = lib, :aliases, key, value) do
%{lib | aliases: Map.put(aliases, key, value)}
end
# --------------------------------------------------------
defp parse_bin(bin) do
@parsers
|> Enum.find_value(fn parser ->
case parser.parse_meta(bin) do
{:ok, meta} -> {:ok, meta}
_ -> false
end
end)
|> case do
{:ok, meta} -> {:ok, meta}
_ -> :error
end
end
# --------------------------------------------------------
defp add_alias(library, new_alias)
defp add_alias(%Static{aliases: aliases} = lib, {new_alias, to}) do
case Map.fetch(aliases, to) do
{:ok, hash} ->
assign(lib, :aliases, new_alias, hash)
_ ->
Logger.warn("Attempted to alias #{inspect(new_alias)} to unknown asset: #{inspect(to)}")
lib
end
end
defp add_alias(%Static{module: mod}, src) do
raise """
Invalid :alias list when building assets library #{inspect(mod)}
Received: #{inspect(src)}
Expected a list of sources in the format of
[alias_one: relative_path, alias_two: {otp_app, relative_path}]
"""
end
end