feat: pipeline to create a full mac app
This commit is contained in:
@@ -3,12 +3,13 @@ defmodule BDS.Desktop.DeepLink do
|
||||
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
||||
shell (spec: script.allium `BlogmarkReceived`).
|
||||
|
||||
On macOS the app bundle registers `bds2://` as a custom URL scheme (see the
|
||||
`CFBundleURLTypes` entry in the packaged `Info.plist`). When the browser
|
||||
bookmarklet navigates to `bds2://new-post?title=&url=`, the OS launches/raises
|
||||
the app and `Desktop.Env` delivers an `{:open_url, [url]}` event. This
|
||||
GenServer subscribes to those events and forwards recognised `bds2://` links to
|
||||
the live shell over PubSub, where `BDS.Blogmark` turns them into draft posts.
|
||||
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
||||
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
||||
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
||||
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
||||
delivers an `{:open_url, [url]}` event. This GenServer subscribes to those
|
||||
events and forwards recognised `bds2://` links to the live shell over PubSub,
|
||||
where `BDS.Blogmark` turns them into draft posts.
|
||||
|
||||
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
||||
installs do not contend for the same registration.
|
||||
|
||||
179
lib/bds/mac_bundle.ex
Normal file
179
lib/bds/mac_bundle.ex
Normal file
@@ -0,0 +1,179 @@
|
||||
defmodule BDS.MacBundle do
|
||||
@moduledoc """
|
||||
Assembles a self-contained, ad-hoc-signed macOS `.app` bundle for bDS2.
|
||||
|
||||
The bundle wraps the `:bds` Elixir release (which already embeds ERTS), a
|
||||
red-pen `AppIcon.icns` (`BDS.MacBundle.Icon`), and the wxWidgets dylibs the
|
||||
`:wx` NIF needs, relocated to run on a clean Mac (`BDS.MacBundle.Dylibs`). Its
|
||||
`Info.plist` registers the `bds2://` URL scheme so the OS forwards blogmark
|
||||
deep links to the running app (`Desktop.Env` → `BDS.Desktop.DeepLink`).
|
||||
|
||||
Layout:
|
||||
|
||||
BDS2.app/Contents/
|
||||
Info.plist PkgInfo
|
||||
MacOS/bds2 # launcher script (execs the release)
|
||||
Resources/AppIcon.icns
|
||||
Resources/rel/ # the mix release (ERTS bundled)
|
||||
Frameworks/ # relocated wx + transitive dylibs
|
||||
"""
|
||||
|
||||
alias BDS.MacBundle.Dylibs
|
||||
|
||||
@identifier "de.rfc1437.bds2"
|
||||
@display_name "Blogging Desktop Server"
|
||||
@executable "bds2"
|
||||
@icon_name "AppIcon"
|
||||
@scheme "bds2"
|
||||
@min_system "13.0"
|
||||
@release_name "bds"
|
||||
@app_dir_name "BDS2.app"
|
||||
|
||||
@doc "Render the `Info.plist` XML for the bundle."
|
||||
@spec info_plist(keyword()) :: String.t()
|
||||
def info_plist(opts \\ []) do
|
||||
assigns = [
|
||||
identifier: Keyword.get(opts, :identifier, @identifier),
|
||||
name: Keyword.get(opts, :name, @display_name),
|
||||
executable: Keyword.get(opts, :executable, @executable),
|
||||
icon: Keyword.get(opts, :icon, @icon_name),
|
||||
scheme: Keyword.get(opts, :scheme, @scheme),
|
||||
min_system: Keyword.get(opts, :min_system, @min_system),
|
||||
version: Keyword.get(opts, :version, "0.0.0")
|
||||
]
|
||||
|
||||
EEx.eval_file(template_path("Info.plist.eex"), assigns)
|
||||
end
|
||||
|
||||
@doc "Render the `Contents/MacOS` launcher shell script."
|
||||
@spec launcher(keyword()) :: String.t()
|
||||
def launcher(opts \\ []) do
|
||||
assigns = [
|
||||
identifier: Keyword.get(opts, :identifier, @identifier),
|
||||
name: Keyword.get(opts, :name, @display_name),
|
||||
release_name: Keyword.get(opts, :release_name, @release_name)
|
||||
]
|
||||
|
||||
EEx.eval_file(template_path("launcher.sh.eex"), assigns)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Assemble the `.app` from a built release directory.
|
||||
|
||||
Options:
|
||||
* `:release_dir` — path to the `:bds` release (required)
|
||||
* `:output_dir` — where to place `BDS2.app` (required)
|
||||
* `:icns_path` — path to `AppIcon.icns` (required)
|
||||
* `:version` — version string for `Info.plist` (default "0.0.0")
|
||||
* `:skip_dylibs` — skip wx dylib relocation (tests; default false)
|
||||
* `:skip_codesign`— skip ad-hoc codesign (tests; default false)
|
||||
"""
|
||||
@spec assemble(keyword()) :: {:ok, String.t()} | {:error, term()}
|
||||
def assemble(opts) do
|
||||
release_dir = Keyword.fetch!(opts, :release_dir)
|
||||
output_dir = Keyword.fetch!(opts, :output_dir)
|
||||
icns_path = Keyword.fetch!(opts, :icns_path)
|
||||
version = Keyword.get(opts, :version, "0.0.0")
|
||||
|
||||
app = Path.join(output_dir, @app_dir_name)
|
||||
contents = Path.join(app, "Contents")
|
||||
macos = Path.join(contents, "MacOS")
|
||||
resources = Path.join(contents, "Resources")
|
||||
frameworks = Path.join(contents, "Frameworks")
|
||||
rel = Path.join(resources, "rel")
|
||||
|
||||
File.rm_rf!(app)
|
||||
Enum.each([macos, resources, frameworks], &File.mkdir_p!/1)
|
||||
|
||||
with {:ok, _} <- File.cp_r(release_dir, rel),
|
||||
:ok <- File.cp(icns_path, Path.join(resources, "#{@icon_name}.icns")),
|
||||
:ok <- File.write(Path.join(contents, "Info.plist"), info_plist(version: version)),
|
||||
:ok <- File.write(Path.join(contents, "PkgInfo"), "APPL????"),
|
||||
:ok <- write_launcher(Path.join(macos, @executable)),
|
||||
:ok <- maybe_relocate_dylibs(rel, frameworks, opts),
|
||||
:ok <- maybe_codesign(app, opts) do
|
||||
{:ok, app}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Locate the wxWidgets NIF (`wxe_driver.so`) inside a release tree."
|
||||
@spec wx_nif_path(String.t()) :: String.t() | nil
|
||||
def wx_nif_path(release_dir) do
|
||||
release_dir
|
||||
|> Path.join("lib/wx-*/priv/wxe_driver.so")
|
||||
|> Path.wildcard()
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
defp write_launcher(path) do
|
||||
with :ok <- File.write(path, launcher()) do
|
||||
File.chmod(path, 0o755)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_relocate_dylibs(_rel, _frameworks, opts) do
|
||||
if Keyword.get(opts, :skip_dylibs, false) do
|
||||
:ok
|
||||
else
|
||||
relocate_dylibs(opts[:release_dir], opts)
|
||||
end
|
||||
end
|
||||
|
||||
defp relocate_dylibs(release_dir, opts) do
|
||||
app = Path.join(opts[:output_dir], @app_dir_name)
|
||||
frameworks = Path.join(app, "Contents/Frameworks")
|
||||
|
||||
case wx_nif_path(Path.join(app, "Contents/Resources/rel")) do
|
||||
nil ->
|
||||
{:error, {:wx_nif_not_found, release_dir}}
|
||||
|
||||
nif ->
|
||||
prefix = "@loader_path/" <> relative_up(Path.dirname(nif), frameworks)
|
||||
|
||||
case Dylibs.bundle(nif, frameworks, prefix) do
|
||||
{:ok, _copied} -> :ok
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Compute a `../`-style relative path from `from_dir` to `to` (both absolute),
|
||||
e.g. the path from the wx NIF's directory up to `Contents/Frameworks`.
|
||||
"""
|
||||
@spec relative_up(String.t(), String.t()) :: String.t()
|
||||
def relative_up(from_dir, to) do
|
||||
from = Path.split(Path.expand(from_dir))
|
||||
target = Path.split(Path.expand(to))
|
||||
{rest_from, rest_to} = drop_common(from, target)
|
||||
(List.duplicate("..", length(rest_from)) ++ rest_to) |> Path.join()
|
||||
end
|
||||
|
||||
defp drop_common([h | from], [h | to]), do: drop_common(from, to)
|
||||
defp drop_common(from, to), do: {from, to}
|
||||
|
||||
defp maybe_codesign(app, opts) do
|
||||
if Keyword.get(opts, :skip_codesign, false), do: :ok, else: codesign(app)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Ad-hoc codesign the whole bundle recursively. `--deep` signs every nested
|
||||
Mach-O (the relocated dylibs and the release's `beam.smp`/`erlexec`/escripts),
|
||||
then we verify with `--deep --strict`.
|
||||
"""
|
||||
@spec codesign(String.t()) :: :ok | {:error, term()}
|
||||
def codesign(app) do
|
||||
with :ok <- run("codesign", ["--force", "--deep", "--sign", "-", "--timestamp=none", app]) do
|
||||
run("codesign", ["--verify", "--deep", "--strict", app])
|
||||
end
|
||||
end
|
||||
|
||||
defp run(cmd, args) do
|
||||
case System.cmd(cmd, args, stderr_to_stdout: true) do
|
||||
{_out, 0} -> :ok
|
||||
{out, status} -> {:error, {:command_failed, cmd, status, out}}
|
||||
end
|
||||
end
|
||||
|
||||
defp template_path(name), do: Path.join(:code.priv_dir(:bds), "desktop/macos/#{name}")
|
||||
end
|
||||
158
lib/bds/mac_bundle/dylibs.ex
Normal file
158
lib/bds/mac_bundle/dylibs.ex
Normal file
@@ -0,0 +1,158 @@
|
||||
defmodule BDS.MacBundle.Dylibs do
|
||||
@moduledoc """
|
||||
Makes the bundled Elixir release self-contained on macOS by relocating the
|
||||
non-system dynamic libraries the wxWidgets NIF (`wxe_driver.so`) links against.
|
||||
|
||||
Erlang's `:wx` driver is built against the wxWidgets dylibs of whatever host
|
||||
produced the OTP install (typically Homebrew under `/opt/homebrew`), so a
|
||||
release copied to a clean Mac would fail to load `:wx`. We copy every external
|
||||
dylib (and its transitive deps) into `Contents/Frameworks/`, then rewrite the
|
||||
install names with `install_name_tool` to `@executable_path/../Frameworks/...`.
|
||||
|
||||
The `otool`/`install_name_tool` parsing and argument building are pure so they
|
||||
can be unit-tested; `bundle/2` performs the actual filesystem + tool work.
|
||||
"""
|
||||
|
||||
# Homebrew prefixes whose libraries must be copied into the bundle. Anything
|
||||
# under /usr/lib or /System is part of macOS and stays referenced in place;
|
||||
# @rpath/@executable_path/@loader_path are already relocatable.
|
||||
@external_prefixes ["/opt/homebrew", "/usr/local"]
|
||||
|
||||
@doc "Parse `otool -L` output into the ordered list of dependency paths."
|
||||
@spec parse_otool(String.t()) :: [String.t()]
|
||||
def parse_otool(output) when is_binary(output) do
|
||||
output
|
||||
|> String.split("\n", trim: true)
|
||||
# The first line is the inspected file ("path:"), not a dependency.
|
||||
|> Enum.filter(&String.starts_with?(&1, "\t"))
|
||||
|> Enum.map(fn line ->
|
||||
line
|
||||
|> String.trim()
|
||||
|> String.replace(~r/\s+\(compatibility version.*\)$/, "")
|
||||
|> String.trim()
|
||||
end)
|
||||
end
|
||||
|
||||
@doc "True when a dylib path must be copied into the bundle (Homebrew/local)."
|
||||
@spec external?(String.t()) :: boolean()
|
||||
def external?(path) when is_binary(path) do
|
||||
Enum.any?(@external_prefixes, &String.starts_with?(path, &1))
|
||||
end
|
||||
|
||||
@doc "install_name_tool args to set a dylib's own id."
|
||||
@spec id_args(String.t(), String.t()) :: [String.t()]
|
||||
def id_args(dylib_path, new_id), do: ["-id", new_id, dylib_path]
|
||||
|
||||
@doc """
|
||||
install_name_tool args to rewrite zero or more dependency install names on a
|
||||
binary. Returns `[]` when there are no changes so callers can skip the tool.
|
||||
"""
|
||||
@spec change_args(String.t(), [{String.t(), String.t()}]) :: [String.t()]
|
||||
def change_args(_binary, []), do: []
|
||||
|
||||
def change_args(binary, changes) when is_list(changes) do
|
||||
Enum.flat_map(changes, fn {old, new} -> ["-change", old, new] end) ++ [binary]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Copy every external dylib reachable from `nif_path` into `frameworks_dir` and
|
||||
rewrite all install names (in the NIF and the copied dylibs) to point inside
|
||||
the bundle. Returns `{:ok, copied_paths}`.
|
||||
|
||||
References are made `@loader_path`-relative so they resolve against the binary
|
||||
that triggers the load — independent of where the host process executable
|
||||
lives (the release runs `beam.smp`, not the bundle launcher) and without any
|
||||
`DYLD_*` env reliance:
|
||||
|
||||
* the NIF references each dylib as `<nif_loader_prefix>/<basename>`, where
|
||||
`nif_loader_prefix` is the `@loader_path/../…/Frameworks` path from the
|
||||
NIF's directory to `frameworks_dir`;
|
||||
* the copied dylibs live together, so they reference each other (and their
|
||||
own id) as `@loader_path/<basename>`.
|
||||
"""
|
||||
@spec bundle(String.t(), String.t(), String.t()) ::
|
||||
{:ok, [String.t()]} | {:error, term()}
|
||||
def bundle(nif_path, frameworks_dir, nif_loader_prefix) do
|
||||
File.mkdir_p!(frameworks_dir)
|
||||
|
||||
with {:ok, {_seen, externals}} <- collect(nif_path, MapSet.new(), []) do
|
||||
copied =
|
||||
externals
|
||||
|> Enum.reverse()
|
||||
|> Enum.map(fn src ->
|
||||
dest = Path.join(frameworks_dir, Path.basename(src))
|
||||
File.cp!(src, dest)
|
||||
File.chmod!(dest, 0o644)
|
||||
{src, dest}
|
||||
end)
|
||||
|
||||
with :ok <- rewrite(nif_path, copied, nif_loader_prefix),
|
||||
:ok <- rewrite_each(copied) do
|
||||
{:ok, Enum.map(copied, &elem(&1, 1))}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Depth-first transitive collection of external dependency paths. Returns
|
||||
# `{:ok, {seen, acc}}` where `acc` is the reverse-discovery-order path list.
|
||||
defp collect(binary, seen, acc) do
|
||||
case otool(binary) do
|
||||
{:ok, deps} ->
|
||||
deps
|
||||
|> Enum.filter(&external?/1)
|
||||
|> Enum.reject(&MapSet.member?(seen, &1))
|
||||
|> Enum.reduce_while({:ok, {seen, acc}}, fn dep, {:ok, {seen_acc, list_acc}} ->
|
||||
seen_acc = MapSet.put(seen_acc, dep)
|
||||
|
||||
case collect(dep, seen_acc, [dep | list_acc]) do
|
||||
{:ok, _} = ok -> {:cont, ok}
|
||||
error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
defp rewrite(binary, copied, prefix) do
|
||||
changes =
|
||||
Enum.map(copied, fn {src, dest} ->
|
||||
{src, prefix <> "/" <> Path.basename(dest)}
|
||||
end)
|
||||
|
||||
case change_args(binary, changes) do
|
||||
[] -> :ok
|
||||
args -> run("install_name_tool", args)
|
||||
end
|
||||
end
|
||||
|
||||
# Copied dylibs share Frameworks/, so they reference each other (and their own
|
||||
# id) relative to their own location with @loader_path.
|
||||
defp rewrite_each(copied) do
|
||||
Enum.reduce_while(copied, :ok, fn {_src, dest}, _acc ->
|
||||
base = Path.basename(dest)
|
||||
|
||||
with :ok <- run("install_name_tool", id_args(dest, "@loader_path/" <> base)),
|
||||
:ok <- rewrite(dest, copied, "@loader_path") do
|
||||
{:cont, :ok}
|
||||
else
|
||||
error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp otool(path) do
|
||||
case System.cmd("otool", ["-L", path], stderr_to_stdout: true) do
|
||||
{out, 0} -> {:ok, parse_otool(out)}
|
||||
{out, status} -> {:error, {:otool_failed, status, out}}
|
||||
end
|
||||
end
|
||||
|
||||
defp run(cmd, args) do
|
||||
case System.cmd(cmd, args, stderr_to_stdout: true) do
|
||||
{_out, 0} -> :ok
|
||||
{out, status} -> {:error, {:command_failed, cmd, status, out}}
|
||||
end
|
||||
end
|
||||
end
|
||||
195
lib/bds/mac_bundle/icon.ex
Normal file
195
lib/bds/mac_bundle/icon.ex
Normal file
@@ -0,0 +1,195 @@
|
||||
defmodule BDS.MacBundle.Icon do
|
||||
@moduledoc """
|
||||
Builds the macOS `AppIcon.icns` for the bDS2 bundle.
|
||||
|
||||
The source artwork is the legacy app's pen icon (a gold pen on a gold disc,
|
||||
imported as `priv/desktop/macos/icon-source.svg`). The bundle icon recolours
|
||||
the **blue** pen to **red** by rotating any colour whose hue sits in the blue
|
||||
band, leaving the gold body and warm/neutral tones untouched. Recolouring the
|
||||
SVG text is fully deterministic and unit-testable; rasterisation then uses the
|
||||
bundled libvips (`Image`) so no extra system tooling is required for SVG→PNG.
|
||||
|
||||
`.icns` packaging itself shells out to the macOS-only `sips`/`iconutil`.
|
||||
"""
|
||||
|
||||
# Hue band (degrees) that counts as "blue" and gets rotated to red.
|
||||
@blue_low 195
|
||||
@blue_high 265
|
||||
# Rotation that maps the pen's ~218–221° blues onto ~358–1° (red).
|
||||
@hue_shift 220
|
||||
|
||||
# Standard macOS iconset base sizes; each is emitted at @1x and @2x.
|
||||
@iconset_sizes [16, 32, 128, 256, 512]
|
||||
|
||||
@doc "Absolute path to the (blue) source SVG bundled in priv."
|
||||
@spec source_svg_path() :: String.t()
|
||||
def source_svg_path do
|
||||
Application.get_env(:bds, :mac_icon_source_svg) ||
|
||||
Path.join(:code.priv_dir(:bds), "desktop/macos/icon-source.svg")
|
||||
end
|
||||
|
||||
@doc "Default output path for the generated `.icns`."
|
||||
@spec icns_path() :: String.t()
|
||||
def icns_path, do: Path.join(:code.priv_dir(:bds), "desktop/macos/AppIcon.icns")
|
||||
|
||||
@doc """
|
||||
Recolour a single `#RRGGBB` value. Blue-band colours are hue-rotated to red;
|
||||
everything else is returned unchanged (byte-for-byte, no rounding drift).
|
||||
"""
|
||||
@spec recolor_color(String.t()) :: String.t()
|
||||
def recolor_color("#" <> _ = hex) do
|
||||
{r, g, b} = to_rgb(hex)
|
||||
{h, s, l} = rgb_to_hsl(r, g, b)
|
||||
|
||||
if h >= @blue_low and h < @blue_high do
|
||||
new_h = :math.fmod(h - @hue_shift + 360.0, 360.0)
|
||||
{nr, ng, nb} = hsl_to_rgb(new_h, s, l)
|
||||
to_hex(nr, ng, nb)
|
||||
else
|
||||
String.upcase(hex)
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Recolour every `#RRGGBB` literal found in an SVG (or any text)."
|
||||
@spec recolor_svg(String.t()) :: String.t()
|
||||
def recolor_svg(svg) when is_binary(svg) do
|
||||
Regex.replace(~r/#[0-9A-Fa-f]{6}/, svg, fn hex -> recolor_color(hex) end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Generate the `.icns` from the source SVG. Returns `{:ok, icns_path}`.
|
||||
|
||||
Steps: recolour SVG → render a 1024² PNG via libvips → emit the iconset sizes
|
||||
with `sips` → pack with `iconutil`.
|
||||
"""
|
||||
@spec generate(keyword()) :: {:ok, String.t()} | {:error, term()}
|
||||
def generate(opts \\ []) do
|
||||
source = Keyword.get(opts, :source_svg, source_svg_path())
|
||||
icns = Keyword.get(opts, :icns_path, icns_path())
|
||||
png_1024 = Keyword.get(opts, :png_path, Path.rootname(icns) <> "-1024.png")
|
||||
|
||||
with {:ok, svg} <- File.read(source),
|
||||
:ok <- render_png(recolor_svg(svg), png_1024, 1024),
|
||||
{:ok, iconset} <- build_iconset(png_1024),
|
||||
:ok <- run("iconutil", ["-c", "icns", "-o", icns, iconset]) do
|
||||
File.rm_rf(iconset)
|
||||
{:ok, icns}
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Render an SVG string to a square PNG of `size` pixels via libvips."
|
||||
@spec render_png(String.t(), String.t(), pos_integer()) :: :ok | {:error, term()}
|
||||
def render_png(svg, png_path, size) do
|
||||
with {:ok, image} <- Image.from_binary(svg),
|
||||
{:ok, resized} <- Image.thumbnail(image, size, height: size),
|
||||
{:ok, _} <- Image.write(resized, png_path) do
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp build_iconset(png_1024) do
|
||||
iconset = Path.rootname(png_1024) <> ".iconset"
|
||||
File.rm_rf!(iconset)
|
||||
File.mkdir_p!(iconset)
|
||||
|
||||
Enum.reduce_while(@iconset_sizes, {:ok, iconset}, fn base, acc ->
|
||||
with :ok <- emit_size(png_1024, iconset, base, 1),
|
||||
:ok <- emit_size(png_1024, iconset, base, 2) do
|
||||
{:cont, acc}
|
||||
else
|
||||
error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp emit_size(png_1024, iconset, base, scale) do
|
||||
px = base * scale
|
||||
suffix = if scale == 1, do: "#{base}x#{base}", else: "#{base}x#{base}@2x"
|
||||
out = Path.join(iconset, "icon_#{suffix}.png")
|
||||
run("sips", ["-z", Integer.to_string(px), Integer.to_string(px), png_1024, "--out", out])
|
||||
end
|
||||
|
||||
defp run(cmd, args) do
|
||||
case System.cmd(cmd, args, stderr_to_stdout: true) do
|
||||
{_out, 0} -> :ok
|
||||
{out, status} -> {:error, {:command_failed, cmd, status, out}}
|
||||
end
|
||||
end
|
||||
|
||||
# ---- colour space helpers -------------------------------------------------
|
||||
|
||||
defp to_rgb("#" <> hex) do
|
||||
{
|
||||
String.to_integer(String.slice(hex, 0, 2), 16),
|
||||
String.to_integer(String.slice(hex, 2, 2), 16),
|
||||
String.to_integer(String.slice(hex, 4, 2), 16)
|
||||
}
|
||||
end
|
||||
|
||||
defp to_hex(r, g, b) do
|
||||
"#" <> pad(r) <> pad(g) <> pad(b)
|
||||
end
|
||||
|
||||
defp pad(v) do
|
||||
v
|
||||
|> max(0)
|
||||
|> min(255)
|
||||
|> Integer.to_string(16)
|
||||
|> String.upcase()
|
||||
|> String.pad_leading(2, "0")
|
||||
end
|
||||
|
||||
defp rgb_to_hsl(r, g, b) do
|
||||
rf = r / 255.0
|
||||
gf = g / 255.0
|
||||
bf = b / 255.0
|
||||
|
||||
max = Enum.max([rf, gf, bf])
|
||||
min = Enum.min([rf, gf, bf])
|
||||
l = (max + min) / 2.0
|
||||
d = max - min
|
||||
|
||||
if d == 0.0 do
|
||||
{0.0, 0.0, l}
|
||||
else
|
||||
s = if l > 0.5, do: d / (2.0 - max - min), else: d / (max + min)
|
||||
|
||||
h =
|
||||
cond do
|
||||
max == rf -> :math.fmod((gf - bf) / d + 6.0, 6.0)
|
||||
max == gf -> (bf - rf) / d + 2.0
|
||||
true -> (rf - gf) / d + 4.0
|
||||
end
|
||||
|
||||
{h * 60.0, s, l}
|
||||
end
|
||||
end
|
||||
|
||||
defp hsl_to_rgb(_h, s, l) when s == 0.0 do
|
||||
v = round(l * 255.0)
|
||||
{v, v, v}
|
||||
end
|
||||
|
||||
defp hsl_to_rgb(h, s, l) do
|
||||
q = if l < 0.5, do: l * (1.0 + s), else: l + s - l * s
|
||||
p = 2.0 * l - q
|
||||
hk = h / 360.0
|
||||
|
||||
{
|
||||
round(hue_to_channel(p, q, hk + 1.0 / 3.0) * 255.0),
|
||||
round(hue_to_channel(p, q, hk) * 255.0),
|
||||
round(hue_to_channel(p, q, hk - 1.0 / 3.0) * 255.0)
|
||||
}
|
||||
end
|
||||
|
||||
defp hue_to_channel(p, q, t) do
|
||||
t = :math.fmod(t + 1.0, 1.0)
|
||||
|
||||
cond do
|
||||
t < 1.0 / 6.0 -> p + (q - p) * 6.0 * t
|
||||
t < 1.0 / 2.0 -> q
|
||||
t < 2.0 / 3.0 -> p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
||||
true -> p
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user