lib/mix/tasks/update.ex

defmodule Mix.Tasks.Pe.Update do
  @moduledoc """
    SYNTAX: mix pe.update (options) <filename>

    pe.update updates the PE-checksum of the given pe file and
    additionally can add resources to it if needed.

    Options are:

        -h | -help                        This help
        --set-subsystem <subsystemcode>   Update the PE files subsytem type
        --set-icon <filename>             Embeds a given application icon
        --get-icon <filename>             Extracts an embedded icon and stores it to the filename
        --set-manifest <filename>         Embeds a given side-by-side manifest
        --set-info <info_type> <value>    Embeds the given version information
        --set-resource <type> <filename>  Embeds any resources type
        --del-resource <type>             Remove any resources type

    Known info types are:

      "Comments", "CompanyName", "FileDescription", "FileVersion", "InternalName",
      "LegalCopyright", "LegalTrademarks", "OriginalFilename", "PrivateBuild",
      "ProductName", "ProductVersion", "SpecialBuild"

    Known resources types are:

      "RT_ACCELERATOR", "RT_ANICURSOR", "RT_ANIICON", "RT_BITMAP", "RT_CURSOR",
      "RT_DIALOG", "RT_DLGINCLUDE", "RT_FONT", "RT_FONTDIR", "RT_GROUP_CURSOR",
      "RT_GROUP_ICON", "RT_HTML", "RT_ICON", "RT_MANIFEST", "RT_MENU",
      "RT_MESSAGETABLE", "RT_PLUGPLAY", "RT_RCDATA", "RT_STRING", "RT_VERSION",
      "RT_VXD"
  """
  use Mix.Task

  @doc false
  def run([]) do
    show_help()
  end

  def run(args) do
    %{files: files, resources: resources, resource_updates: resource_updates, updates: updates} =
      process_args(%{resources: [], resource_updates: %{}, files: [], updates: []}, args)

    if files == [] do
      error("No files given")
    end

    Enum.each(files, fn filename ->
      {:ok, pe} = LibPE.parse_file(filename)

      IO.puts("Updating file #{filename}")

      raw =
        update_resources(pe, resources, resource_updates)
        |> update_other(updates)
        |> LibPE.update_checksum()
        |> LibPE.encode()

      File.write!(filename, raw)
    end)
  end

  def error(msg) do
    Mix.Shell.IO.error(msg)
    # Mix.Shell.IO.info(@moduledoc)
    System.halt(1)
  end

  defp show_help() do
    Mix.Shell.IO.info(@moduledoc)
    System.halt()
  end

  defp update_other(pe, updates) do
    Enum.reduce(updates, pe, fn update, pe ->
      update.(pe)
    end)
  end

  defp update_resources(pe, replacements, resource_updates) do
    if replacements == [] and map_size(resource_updates) == 0 do
      pe
    else
      do_update_resources(pe, replacements, resource_updates)
    end
  end

  defp do_update_resources(pe, replacements, resource_updates) do
    resource_table = LibPE.get_resources(pe)

    resource_table =
      Enum.reduce(replacements, resource_table, fn {type, data}, resource_table ->
        LibPE.ResourceTable.set_resource(resource_table, type, data)
      end)

    resource_table =
      Enum.reduce(resource_updates, resource_table, fn {type, funs}, resource_table ->
        resource = LibPE.ResourceTable.get_resource(resource_table, type)

        new_resource =
          Enum.reduce(funs, resource, fn fun, resource ->
            fun.(resource)
          end)

        if new_resource != resource do
          LibPE.ResourceTable.set_resource(resource_table, type, new_resource)
        else
          resource_table
        end
      end)

    LibPE.set_resources(pe, resource_table)
    |> LibPE.update_layout()
  end

  defp process_args(opts, []) do
    opts
  end

  defp process_args(opts, ["--set-manifest", filename | rest]) do
    case File.read(filename) do
      {:ok, data} ->
        add_resource(opts, "RT_MANIFEST", data)
        |> process_args(rest)

      error ->
        error("Failed to read manifest file #{filename}: #{inspect(error)}")
    end
    |> process_args(rest)
  end

  defp process_args(opts, ["--set-subsystem", value | rest]) do
    LibPE.WindowsSubsystem.flags()
    |> Enum.find(fn {name, num, _desc} -> value == name or value == "#{num}" end)
    |> case do
      nil ->
        error(
          "Failed find subsystem value '#{value}'. Valid values are: #{inspect(LibPE.WindowsSubsystem.flags())}"
        )

      sub ->
        add_update(opts, fn pe -> %{pe | coff_header: %{pe.coff_header | subsystem: sub}} end)
        |> process_args(rest)
    end
  end

  defp process_args(opts, ["--set-icon", filename | rest]) do
    case File.read(filename) do
      {:ok, data} ->
        opts
        |> add_resource("RT_ICON", data)
        |> drop_resource("RT_GROUP_ICON")
        |> process_args(rest)

      error ->
        error("Failed to read icon file #{filename}: #{inspect(error)}")
    end
  end

  defp process_args(opts, ["--get-icon", filename | rest]) do
    if File.exists?(filename) do
      error("The output file already exists: #{filename}")
    end

    update_resource(opts, "RT_ICON", fn icon ->
      if icon == nil do
        Mix.Shell.IO.error("No icon found in the file")
      else
        File.write!(filename, icon.entry.data)
      end

      icon
    end)
    |> process_args(rest)
  end

  defp process_args(opts, ["--set-resource", name, filename | rest]) do
    data =
      case File.read(filename) do
        {:ok, data} -> data
        error -> error("Failed to read resource file #{filename}: #{inspect(error)}")
      end

    add_resource(opts, name, data)
    |> process_args(rest)
  end

  defp process_args(opts, ["--del-resource", name | rest]) do
    add_resource(opts, name, nil)
    |> process_args(rest)
  end

  defp process_args(opts, ["--set-info", name, value | rest]) do
    update = fn version ->
      page = LibPE.Codepage.encode(0)
      lang = LibPE.Language.encode(1033)

      version =
        version ||
          %LibPE.ResourceTable.DirEntry{
            name: lang,
            entry: %LibPE.ResourceTable.DataBlob{
              codepage: page,
              data: LibPE.VersionInfo.encode(LibPE.VersionInfo.new())
            }
          }

      data =
        LibPE.VersionInfo.decode(version.entry.data)
        |> Map.update!(:strings, fn strings -> List.keystore(strings, name, 0, {name, value}) end)
        |> LibPE.VersionInfo.encode()

      %{version | entry: %{version.entry | data: data}}
    end

    update_resource(opts, "RT_VERSION", update)
    |> process_args(rest)
  end

  defp process_args(_opts, ["--help" | _rest]), do: show_help()
  defp process_args(_opts, ["-h" | _rest]), do: show_help()

  defp process_args(opts, [arg | rest]) do
    if String.starts_with?(arg, "-") do
      error("Unknown option string '#{arg}'")
    end

    %{opts | files: [arg | opts.files]}
    |> process_args(rest)
  end

  defp drop_resource(opts, name) do
    add_resource(opts, name, nil)
  end

  defp add_update(opts, update_fun) do
    %{opts | updates: [update_fun | opts.updates]}
  end

  defp add_resource(opts, name, data) do
    if not is_integer(name) and not LibPE.Flags.is_name(LibPE.ResourceTypes, name) do
      error("""
        The specified resource name #{name} is not a known name. Known resource names
        are only:

        #{inspect(LibPE.Flags.names(LibPE.ResourceTypes))}
      """)
    end

    name = LibPE.ResourceTypes.encode(name)
    %{opts | resources: [{name, data} | opts.resources]}
  end

  defp update_resource(opts, name, fun) do
    if not is_integer(name) and not LibPE.Flags.is_name(LibPE.ResourceTypes, name) do
      error("""
        The specified resource name #{name} is not a known name. Known resource names
        are only:

        #{inspect(LibPE.Flags.names(LibPE.ResourceTypes))}
      """)
    end

    name = LibPE.ResourceTypes.encode(name)

    %{
      opts
      | resource_updates:
          Map.update(opts.resource_updates, name, [fun], fn rest -> rest ++ [fun] end)
    }
  end
end