[Windows] GitHub APIから最新のHugoをダウンロードしてインストールする

GitHub API からダウンロードリンクを取得できる、と知ったので、 C#でAPIから取得できるようにしたら無駄に苦労した。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Web.Script.Serialization;

class Program
{
  static void Main(String[] args)
  {
    new Program().SetupOrUpdateHugo(
      "ここにインストール先のフォルダを指定して下さい。" +
      "(Please specify the destination folder here.)");
  }

  private void SetupOrUpdateHugo(string installFolder)
  {
    using (var client = new WebClient())
    {
      // `User-Agent`をセットする理由は`Github API`がエラーを返す仕様の対策
      client.Headers["User-Agent"] = "Mozilla/5.0 " +
        "(Linux; Android 6.0; Nexus 5 Build/MRA58N)" +
        " AppleWebKit/537.36 (KHTML, like Gecko)" +
        " Chrome/71.0.3578.98 Mobile Safari/537.36";

      ServicePointManager.SecurityProtocol =
        SecurityProtocolType.Ssl3 |
        SecurityProtocolType.Tls11 |
        SecurityProtocolType.Tls12;

      var releasesText = client.DownloadString(
        "https://api.github.com/repos/gohugoio/hugo/releases");
      var json = new JavaScriptSerializer()
        .Deserialize<object[]>(releasesText);
      string downloadUrl = null;
      foreach (var release in json)
      {
        var assets = (release as Dictionary<string, object>)?["assets"]
          as object[];
        foreach (var asset in assets)
        {
          var assetDict = asset as Dictionary<string, object>;
          var name = assetDict["name"] as string;
          var x = Environment.Is64BitOperatingSystem ? "64" : "32";
          if (Regex.IsMatch(
            name,
            "hugo.+Windows.+" + x + ".+",
            RegexOptions.IgnoreCase))
          {
            downloadUrl =
              assetDict["browser_download_url"] as string;
            break;
          }
        }
        if (downloadUrl != null) break;
      }

      if (downloadUrl == null)
        throw new InvalidOperationException("downloadurl not found");

      var logPath = Path.Combine(installFolder, "last_downloaded.txt");
      var lastDownloadedUrl =
        File.Exists(logPath) ? File.ReadAllText(logPath) : null;
      if (lastDownloadedUrl != downloadUrl)
      {
        Console.WriteLine("New Version Hugo Found: " + downloadUrl);
        File.WriteAllText(logPath, downloadUrl);
        using (var zipData = new MemoryStream(client.DownloadData(downloadUrl)))
        using (var zipArchive = new ZipArchive(zipData, ZipArchiveMode.Read))
        {
          foreach (var entry in zipArchive.Entries)
          {
            if (entry.Name == "hugo.exe")
            {
              var hugoPath = Path.Combine(installFolder, entry.Name);
              File.Delete(hugoPath);
              using (var hugoExe = entry.Open())
              using (var fileStream = File.Create(hugoPath))
              {
                hugoExe.CopyTo(fileStream);
                break;
              }
            }
          }
        }
      }
    }

    // `hugo.exe` のパスを通す
    AddEnvironmentPath(installFolder, true, EnvironmentVariableTarget.User);
  }

  private void AddEnvironmentPath(
    string path,
    bool needFolder,
    EnvironmentVariableTarget target)
  {
    ValidateDirectoryPath(path);
    path = Path.GetFullPath(path);
    if (File.Exists(path))
      throw new InvalidOperationException("file already exists at " + path);
    if (needFolder && !Directory.Exists(path))
      throw new DirectoryNotFoundException(path + " must be existed directory");

    var pathList = GetEnvironmentPath(target);

    var finalPathList = pathList.Select(p =>
    {
      try
      {
        return NativeMethods.GetFinalPathName(p).TrimEnd('\\');
      }
      catch (Exception)
      {
        // ファイルがない場合、例外が送出されるので元のパスのまま返す
        return p;
      }
    }).Where(p => p != null).ToArray();

    if (!finalPathList.Contains(NativeMethods.GetFinalPathName(path).Trim('\\')))
    {
      pathList.Add(path); // 追加

      SetEnvironmentPath(pathList, target);
    }
  }

  private static IList<string> GetEnvironmentPath(
    EnvironmentVariableTarget target)
  {
    var data = Environment.GetEnvironmentVariable("Path", target);
    var pathList = data.Split(';').Where(p => !string.IsNullOrEmpty(p));
    var ret = pathList.Select(p =>
    {
      return p.TrimEnd('\\');
    }).Distinct().ToList();
    return ret;
  }

  private void SetEnvironmentPath(
    IEnumerable<string> pathList,
    EnvironmentVariableTarget target)
  {
    var oldPath = Environment.GetEnvironmentVariable("Path", target);
    var newPath = string.Join(";", pathList.Distinct());

    if (oldPath != newPath)
    {
      // この処理、とても時間かかることがある。ハングしたと勘違いしないように
      Environment.SetEnvironmentVariable("Path", newPath, target);
    }
  }

  public static void ValidateDirectoryPath(string path)
  {
    if (string.IsNullOrWhiteSpace(path))
      throw new ArgumentException("null or white space", nameof(path));
    foreach (var invalidChar in Path.GetInvalidPathChars())
    {
      if (path.IndexOf(invalidChar) != -1)
        throw new ArgumentException("invalid char(" + invalidChar + ") found", nameof(path));
    }
  }

  public static partial class NativeMethods
  {
    [DllImport("kernel32.dll")]
    public static extern bool CreateSymbolicLink(
      string lpSymlinkFileName,
      string lpTargetFileName,
      SymbolicLink dwFlags);

    public enum SymbolicLink
    {
      File = 0,
      Directory = 1
    }
  }

  public static partial class NativeMethods
  {
    private static readonly IntPtr INVALID_HANDLE_VALUE = new IntPtr(-1);

    private const uint FILE_READ_EA = 0x0008;
    private const uint FILE_FLAG_BACKUP_SEMANTICS = 0x2000000;

    [DllImport("Kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
    static extern uint GetFinalPathNameByHandle(
      IntPtr hFile,
      [MarshalAs(UnmanagedType.LPTStr)] StringBuilder lpszFilePath,
      uint cchFilePath,
      uint dwFlags);

    [DllImport("kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    static extern bool CloseHandle(IntPtr hObject);

    [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)]
    public static extern IntPtr CreateFile(
        [MarshalAs(UnmanagedType.LPTStr)] string filename,
        [MarshalAs(UnmanagedType.U4)] uint access,
        [MarshalAs(UnmanagedType.U4)] FileShare share,
        IntPtr securityAttributes, // optional SECURITY_ATTRIBUTES struct or IntPtr.Zero
        [MarshalAs(UnmanagedType.U4)] FileMode creationDisposition,
        [MarshalAs(UnmanagedType.U4)] uint flagsAndAttributes,
        IntPtr templateFile);

    /// <summary>
    /// https://stackoverflow.com/questions/2302416/in-net-how-to-obtain-the-target-of-a-symbolic-link-or-reparse-point
    /// </summary>
    /// <param name="path"></param>
    /// <returns></returns>
    public static string GetFinalPathName(string path)
    {
      var h = CreateFile(path,
        FILE_READ_EA,
        FileShare.ReadWrite | FileShare.Delete,
        IntPtr.Zero,
        FileMode.Open,
        FILE_FLAG_BACKUP_SEMANTICS,
        IntPtr.Zero);
      if (h == INVALID_HANDLE_VALUE)
        throw new Win32Exception();

      try
      {
        var sb = new StringBuilder(1024);
        var res = GetFinalPathNameByHandle(h, sb, 1024, 0);
        if (res == 0)
          throw new Win32Exception();

        var ret = sb.ToString();
        ret
          = ret.StartsWith(@"\\?\UNC", StringComparison.Ordinal) ? @"\" + ret.Substring(7)
          : ret.StartsWith(@"\\?\", StringComparison.Ordinal) ? ret.Substring(4)
          : ret;
        return ret;
      }
      finally
      {
        CloseHandle(h);
      }
    }
  }
}
Share