C# - 自己解凍書庫 - 定義ファイル追加

## はじめに

[C# - 自己解凍書庫 - 基本機能](https://qiita.com/chai0917/items/e28d02a98ae9fdea76d8) の続編です。

### テスト環境
ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。

- Windows Forms - .NET Framework 4.8

::: note
Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。
:::

### 素材

Windows Forms アイコンとして下記を利用させて頂きました。

- [フリーの圧縮フォルダアイコン | ICOOON MONO](https://icooon-mono.com/12054-%e3%83%95%e3%83%aa%e3%83%bc%e3%81%ae%e5%9c%a7%e7%b8%ae%e3%83%95%e3%82%a9%e3%83%ab%e3%83%80%e3%82%a2%e3%82%a4%e3%82%b3%e3%83%b3/)
  - rgb（255, 102, 0）


## 定義ファイル追加

「自己解凍書庫ランチャー」は、さまざな用途で自己解凍書庫を作成時に、再利用できることが望ましいので、汎用的な単機能とすべきです。

まず、「自己解凍書庫ランチャー」に対する個別の情報は、定義ファイルで設定可能とします。
次に、特殊な処理（簡易的なインストーラ機能）が必要な場合は、該当処理専用の実行可能形式ファイルを作成して、ZIPアーカイブ内に格納、解凍後、自動起動する形態とします。

このような動作を前提とした、定義ファイルを自己解凍書庫に追加します。

### 定義ファイル

定義ファイル設定項目として、考えられる項目を記載します。

- 初期表示ダイアログの「タイトル」「説明文」
- 初期表示ダイアログは利用しない（解凍後、実行可能形式ファイルを実行を前提）
- 解凍先フォルダの既定値
- 解凍後、自動起動する実行可能形式ファイル（解凍フォルダからの相対パス指定）
- 解凍後、解凍データを削除するか否か

本記事では、初期表示ダイアログの「タイトル」「説明文」を、XML で下記のように指定するケースをサンプルとします。

```XML:Hoge.xml
<?xml version="1.0" encoding="utf-8"?>
<Settings>
  <Title>〇〇サンプルプロジェクト</Title>
  <Message>〇〇サンプルプロジェクトを解凍します。
解凍フォルダを指定して、「解凍実行」ボタンを押してください。</Message>
</Settings>
```

![ZIP-10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/c307196b-9f4a-c992-7ad8-c641e8ab03dd.png)


### 自己解凍書庫の構造

「自己解凍書庫ランチャー」「動作定義ファイル」「ZIPアーカイブ」の３ファイルから自己解凍書庫を作成しますが、単純に３ファイルを結合すると「動作定義ファイル」の終端確認が困難です。

::: note
FileStream から StreamReader を生成して、ReadLine という手法の場合、「動作定義ファイル」末尾に改行が入っているか否かなどについての配慮が必要となります。
そもそも、StreamReader がバッファリングをしてしまうので、FileStream の Position から終端を特定することができないですね。

次の手としては、FileStream で Read して、ZIPファイルヘッダ「0x504B0304」を探すことで、「動作定義ファイル」終端を特定することです。
まぁ、この手法でも良いですが、本記事では、この手法は選択しないこととします。
:::

このため、「動作定義ファイル」のサイズを固定長ファイルに出力して、下記４ファイルを結合することにします。
- 「自己解凍書庫ランチャー」
- 「動作定義ファイル」のサイズ（本記事では、８バイト固定ファイルとします）
- 「動作定義ファイル」
- 「ZIPアーカイブ」

![ZIP-11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3963267/3deef37f-b138-ab34-379f-64967479635b.png)

上記手順に基づいた Windows バッチファイルを作成して実行します。

```shell-session
> MakeJoinFile ZipMelt.exe hoge.xml hoge.zip hoge.exe
```

```dosbatch:MakeJoinFile.bat
echo off
@rem 遅延関数のおまじない
setlocal enabledelayedexpansion

@rem 第二引数(設定ファイル)ファイルサイズを6桁0埋め値に整形
@rem echo (\r\n付与)で8バイトファイルを作成
for %%i in (%2) do set SIZE=%%~zi
set NUMBER=000000%SIZE%
set NUMBER=!NUMBER:~-6,6!
echo %NUMBER%>%2.txt

@rem ファイル結合
copy /b %1 + %2.txt + %2 + %3 %4
```

## サンプルコード


### スタートアップルーチン

スタートアップルーチンで下記情報を作成して、Form コンストラクタに情報をセットする形態とします。

- 「定義ファイル」 XML をワークファイルとして作成
- 「ZIPアーカイブ」をワークファイルとして作成

```CSharp:Program.cs
static void Main()
{
  string myself = Assembly.GetExecutingAssembly().Location;  // 自プログラム
  string xmlFile = "Piyo.xml";       // ユニークなワークファイルとしてください
  string zipFile = "Piyo.zip";       // ユニークなワークファイルとしてください 

  using(var fs = new FileStream(myself, FileMode.Open, FileAccess.Read))
  {
    // PEヘッダーからプログラムサイズ算出
    long size = GetSizeFromPeHeader(fs);
    if (size < 0)
    {
      // ERROR - TODO
    }

    // FileStream 位置をプログラム末尾に設定
    fs.Seek(size, SeekOrigin.Begin);

    // FileStream 現在位置から定義ファイルを抽出
    long xmlsize = MyApp2XmlFile(fs, xmlFile);
    if (xmlsize <= 0)
    {
      // ERROR - TODO
    }

    // FileStream 位置を定義ファイル末尾に設定
    fs.Seek(size + xmlsize, SeekOrigin.Begin);

    // FileStream 現在位置からファイル末尾までをZIPファイルに抽出
    MyApp2ZipFile(fs, zipFile);
  }

  // メインフォーム起動
  Application.EnableVisualStyles();
  Application.SetCompatibleTextRenderingDefault(false);
  Application.Run(new Form1(xmlFile, zipFile));

  // 後処理
  if (File.Exists(xmlFile))
  {
    File.Delete(xmlFile);
  }
  if (File.Exists(zipFile))
  {
    File.Delete(zipFile);
  }
}
// FileStream 現在位置から定義ファイルを抽出
private static int MyApp2XmlFile(FileStream fs, string xmlFile)
{
  int tagsize = 8;    // TODO
  var tagbufs = new byte[tagsize];
  int xmlsize = -1;

  // 定義ファイルのサイズ取得
  fs.Read(tagbufs, 0, tagsize);
  int.TryParse(Encoding.UTF8.GetString(tagbufs), out xmlsize);

  // 定義ファイル抽出
  if (xmlsize > 0)
  {
    var xmlbufs = new byte[xmlsize];
    using (var fsxml = new FileStream(xmlFile, FileMode.Create, FileAccess.Write))
    {
      fs.Read(xmlbufs, 0, xmlsize);
      fsxml.Write(xmlbufs, 0, xmlsize);
    }
    // 先頭で取得したサイズ情報 8バイトを追加
    xmlsize += tagsize;
  }
  return xmlsize;
}
```

::: note
GetSizeFromPeHeader、MyApp2ZipFile は、前回の記事で掲載した内容を Program.cs に追加してください。
:::

### メインフォーム

コンストラクタで、xmlFile、zipFile を内部変数に格納した後、xmlFile をロードして、ダイアログ表示内容を変更します。

```CSharp:Form1.cs
private string MyXmlFile = null;
private string MyZipFile = null;

public Form1(string xmlFile, string zipFile)
{
  InitializeComponent();

  // 内部変数保持
  this.MyXmlFile = xmlFile;
  this.MyZipFile = zipFile;

  // XML → オブジェクト
  var obj = LoadFromFile<XmlSettings>(MyXmlFile);

  // ダイアログ表示項目を XML 指定値で更新
  this.Text = obj?.Title ?? this.Text;
  lblMessage.Text = obj?.Message ?? lblMessage.Text;
}
// XML → オブジェクト
private T LoadFromFile<T>(string filePath) where T : class
{
  T obj = null;
  XmlSerializer serializer = new XmlSerializer(typeof(T));

  // ファイル確認
  if (File.Exists(filePath))
  {
    // デシリアライズ
    using (var fs = new FileStream(filePath, FileMode.Open))
    {
      obj = serializer.Deserialize(fs) as T;
    }
  }
  return obj;
}
```

```CSharp
[Serializable]
[XMLRoot Name="Settings"]
public class XmlSettings
{
  [XmlElement]
  public string Title { get; set; }
  [XmlElement]
  public string Message { get; set; }
}
```

次に「解凍実行」Click イベントハンドラで、ZIP解凍を行います。

```CSharp:Form1.cs
private void btnMelt_Click(object sender, EventArgs e)
{
  string targetFolder = txtTargetFolder.Text.Trim();         // ダイアログ指定値

  // ZIP解凍
  using (var arc = ZipFile.OpenRead(this.MyZipFile))
  {
    arc.ExtractToDirectory(targetFolder);
  }
}
```