lib/file_system/backends/fs_mac.ex

require Logger

defmodule FileSystem.Backends.FSMac do
  @moduledoc """
  File system backend for MacOS.

  The built-in executable file will be compile upon first use.

  This file is a fork from https://github.com/synrc/fs.

  ## Backend Options

    * `:latency` (float, default: 0.5), latency period.

    * `:no_defer` (bool, default: false), enable no-defer latency modifier.
      Works with latency parameter.

      See `FSEvent` API documents
      https://developer.apple.com/documentation/coreservices/kfseventstreamcreateflagnodefer.

    * `:watch_root` (bool, default: false), watch for when the root path has changed.
      Set the flag `true` to monitor events when watching `/tmp/fs/dir` and run
      `mv /tmp/fs /tmp/fx`.

      See `FSEvent` API documents
      https://developer.apple.com/documentation/coreservices/kfseventstreamcreateflagwatchroot.

    * recursive is enabled by default and it can'b be disabled for now.

  ## Executable File Path

  Useful when running `:file_system` with escript.

  The default listener executable file is `priv/mac_listener` within the folder of
  `:file_system` application.

  Two ways to customize the executable file path:

    * Module config with `config.exs`:

      ```elixir
      config :file_system, :fs_mac,
        executable_file: "YOUR_EXECUTABLE_FILE_PATH"`
      ```

    * System environment variable:

      ```
      export FILESYSTEM_FSMAC_EXECUTABLE_FILE="YOUR_EXECUTABLE_FILE_PATH"`
      ```
  """

  use GenServer
  @behaviour FileSystem.Backend

  @default_exec_file "mac_listener"

  def bootstrap do
    exec_file = executable_path()

    if not is_nil(exec_file) and File.exists?(exec_file) do
      :ok
    else
      Logger.error("Can't find executable `mac_listener`")
      {:error, :fs_mac_bootstrap_error}
    end
  end

  def supported_systems do
    [{:unix, :darwin}]
  end

  def known_events do
    [
      :mustscansubdirs,
      :userdropped,
      :kerneldropped,
      :eventidswrapped,
      :historydone,
      :rootchanged,
      :mount,
      :unmount,
      :created,
      :removed,
      :inodemetamod,
      :renamed,
      :modified,
      :finderinfomod,
      :changeowner,
      :xattrmod,
      :isfile,
      :isdir,
      :issymlink,
      :ownevent
    ]
  end

  defp executable_path do
    executable_path(:system_env) || executable_path(:config) || executable_path(:system_path) ||
      executable_path(:priv)
  end

  defp executable_path(:config) do
    Application.get_env(:file_system, :fs_mac)[:executable_file]
  end

  defp executable_path(:system_env) do
    System.get_env("FILESYSTEM_FSMAC_EXECUTABLE_FILE")
  end

  defp executable_path(:system_path) do
    System.find_executable(@default_exec_file)
  end

  defp executable_path(:priv) do
    case :code.priv_dir(:file_system) do
      {:error, _} ->
        Logger.error(
          "`priv` dir for `:file_system` application is not available in current runtime, appoint executable file with `config.exs` or `FILESYSTEM_FSMAC_EXECUTABLE_FILE` env."
        )

        nil

      dir when is_list(dir) ->
        Path.join(dir, @default_exec_file)
    end
  end

  def parse_options(options) do
    case Keyword.pop(options, :dirs) do
      {nil, _} ->
        Logger.error("required argument `dirs` is missing")
        {:error, :missing_dirs_argument}

      {dirs, rest} ->
        args = [~c"-F" | dirs |> Enum.map(&Path.absname/1) |> Enum.map(&to_charlist/1)]
        parse_options(rest, args)
    end
  end

  defp parse_options([], result), do: {:ok, result}

  defp parse_options([{:latency, latency} | t], result) do
    result =
      if is_float(latency) or is_integer(latency) do
        [~c"--latency=#{latency / 1}" | result]
      else
        Logger.error("latency should be integer or float, got `#{inspect(latency)}, ignore")
        result
      end

    parse_options(t, result)
  end

  defp parse_options([{:no_defer, true} | t], result) do
    parse_options(t, [~c"--no-defer" | result])
  end

  defp parse_options([{:no_defer, false} | t], result) do
    parse_options(t, result)
  end

  defp parse_options([{:no_defer, value} | t], result) do
    Logger.error("unknown value `#{inspect(value)}` for no_defer, ignore")
    parse_options(t, result)
  end

  defp parse_options([{:with_root, true} | t], result) do
    parse_options(t, [~c"--with-root" | result])
  end

  defp parse_options([{:with_root, false} | t], result) do
    parse_options(t, result)
  end

  defp parse_options([{:with_root, value} | t], result) do
    Logger.error("unknown value `#{inspect(value)}` for with_root, ignore")
    parse_options(t, result)
  end

  defp parse_options([h | t], result) do
    Logger.error("unknown option `#{inspect(h)}`, ignore")
    parse_options(t, result)
  end

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, [])
  end

  def init(args) do
    {worker_pid, rest} = Keyword.pop(args, :worker_pid)

    case parse_options(rest) do
      {:ok, port_args} ->
        port =
          Port.open(
            {:spawn_executable, to_charlist(executable_path())},
            [:stream, :exit_status, {:line, 16384}, {:args, port_args}, {:cd, System.tmp_dir!()}]
          )

        Process.link(port)
        Process.flag(:trap_exit, true)
        {:ok, %{port: port, worker_pid: worker_pid}}

      {:error, _} ->
        :ignore
    end
  end

  def handle_info({port, {:data, {:eol, line}}}, %{port: port} = state) do
    {file_path, events} = line |> parse_line
    send(state.worker_pid, {:backend_file_event, self(), {file_path, events}})
    {:noreply, state}
  end

  def handle_info({port, {:exit_status, _}}, %{port: port} = state) do
    send(state.worker_pid, {:backend_file_event, self(), :stop})
    {:stop, :normal, state}
  end

  def handle_info({:EXIT, port, _reason}, %{port: port} = state) do
    send(state.worker_pid, {:backend_file_event, self(), :stop})
    {:stop, :normal, state}
  end

  def handle_info(_, state) do
    {:noreply, state}
  end

  def parse_line(line) do
    [_, _, events, path] = line |> to_string |> String.split(["\t", "="], parts: 4)

    {path,
     events |> String.split(["[", ",", "]"], trim: true) |> Enum.map(&String.to_existing_atom/1)}
  end
end