「プログラミング」 カテゴリーの記事です。
nuget-icon

.NET FrameworkでDLLファイル(国際化対応リソースを含む)をexeにマージする


前のバージョンまで、「アタッシェケース」ではインストールを行うと、以下のプログラムフォルダーには、本体と並んで、DLLファイルがいくつか並んでいました。

AttacheCase.exe
AtcSetup.exe
Microsoft.WindowsAPICodePack.dll
Microsoft.WindowsAPICodePack.Shell.dll
ja-JP\AttacheCase.resources.dll

このファイル構成は、あくまで開発者側の都合であって、ユーザーにとっては良く分からないでしょう。

実行ファイルを別の場所へ移動させて使いたいとき、このDLLファイルも同時に移動させなくてはいけないのか?と迷ったり、煩わしいと思ったりするかもしれません。

実際、付属DLLファイルによっては、実行ファイルのあるフォルダーに存在しないと、正常に動かないものもあり、ユーザーにとっては、ツールのポータビリティが低く、やや使いづらいのではないかと考えました。

一つの実行ファイルに、すべての付属ファイルをマージする

というわけで、章題。これを目標にしてみました。そこで考えられる方策をいつくかご紹介したいと思います。

ILMerge(ILRepack)

もっとも手軽で定番なのは、「ILMerge」でしょうか。

Microsoft謹製のツールです。リンク先にあるツールをダウンロードしてインストールすると、使えるようになります。いろいろなオプションがあって、それに従って設定すれば、指定の実行ファイルにDLLファイルをマージできるようになります。

コマンドの例としては、以下のような感じです。

"C:\Program Files\Microsoft\ILMerge\ILMerge" /log:ilmerge.log /ndebug /targetplatform:v4,"C:\Windows\Microsoft.NET\Framework\v4.0.30319" /out:AttacheCaseTemp.exe AttacheCase.exe Microsoft.WindowsAPICodePack.dll Microsoft.WindowsAPICodePack.Shell.dll ja-JP\AttacheCase.resources.dll

ところが、これですと、言語ファイルである、ja-JP\AttacheCase.resources.dllが残ってしまいます。どうやら、国際化対応リソースのDLLは「サテライトアセンブリ」といって、マージのときに除外されるみたいです。

ちなみに「ILMerge」は、Microsoftのプロプライエタリなツールですが、これをオープンソースにして、機能を拡張したものも存在します。「ILRepack」です。

これも、ILMergeと基本的に使い方は同じです。たとえば、以下のようにします。

"..\tools\ILRepack.exe" /log:ILRepack.log /ndebug /targetplatform:v4,"C:\Windows\Microsoft.NET\Framework\v4.0.30319" /out:"C:\Users\mhibara\Desktop\AttacheCase.exe" AttacheCase.exe Microsoft.WindowsAPICodePack.dll Microsoft.WindowsAPICodePack.Shell.dll ja-JP\AttacheCase.resources.dll

ただ、これも、肝心の言語ファイルがマージされません。

NuGet

では、より簡単で、すべてのDLLファイルを実行ファイル(exe)にマージできないか調べて見ると、Visual Studio 2012以降では標準で付属しているツールの「NuGet」で、あるパッケージ群をインストールすれば可能になるようです。

まず、NuGetとは何か? マイクロソフト公式のウェブサイトにも説明はありますが、以下のウィキペディアの説明の方が簡潔ですので、そちらを参照なさってください。

NuGet
https://ja.m.wikipedia.org/wiki/NuGet

NuGetは、基本的にVS全体にインストールするというよりも、プロジェクト毎にインストールするようになっています。ですので、以下の画面では、「アタッシェケース」のソリューション(その中に各プロジェクト)が開いている状態で、NuGetパッケージ管理画面を開こうとしています。

 

Fody

次に「参照」から「fody」を検索して、Fodyを見つけたら、NuGetからパッケージを必要とするプロジェクトにチェックを入れ、そこへ「インストール」します。

Fodyとは以下のサイトにも説明がある通り、「Extensible tool for weaving .net assemblies(.NET Frameworkにアセンブリを組み込むための拡張ツール)」です。

Fody
https://github.com/Fody/Fody/

ただ、これだけではまだ不完全です。さらに、次にあるFodyのアドインをインストールする必要があります。

Fody.Costura

再びNuGetで「Fody Costura」を検索してインストールします。

Costuraとは、プロジェクトで使うリソースをアプリケーション本体に埋め込むためのツールです。

Costura is an add-in for Fody
https://github.com/Fody/Costura/

しかし、インストールしただけでは正常に動作しません。その後に、設定ファイルを所定の場所に置かなくてはなりません。ファイル名は以下の通り、決まっています。

FodyWeavers.xml

というファイルを作り、プロジェクトファイルがあるディレクトリに配置しなくてはなりません。

そのXMLファイルの中身ですが、最低限の設定であれば、以下の内容だけでO.K.です。ちなみに、公式GitHubのウェブサイトを見れば、埋め込みを除外するDLLを指定することができたり、様々なオプションが用意されているので、カスタマイズする場合はそちらを参考になさってください。なお、XMLファイルの文字エンコーディングはUTF-8なのでご注意を。



  

これにより、
Microsoft.WindowsAPICodePack.dll
Microsoft.WindowsAPICodePack.Shell.dll
の二つが「AttacheCase.exe」埋め込まれて、以下のようにファイルが出力されるかと思います。

 

Resource.Embedder

しかし、これでも、まだ国際化対応リソース(言語ファイル)DLL、
ja-JP\AttacheCase.resources.dll が統合されておりません。

そこで、もう一つNuGetパッケージをインストールします。「Resource.Embedder」です。

Resource.Embedder
https://github.com/MarcStan/Resource.Embedder

インストールは、以下の通りです。

これにより、ビルドを行うと、すべてのDLLリソースが「AttacheCase.exe」に埋め込まれて、以下のように単一で出力されます。

 

ただし、.NET Framework 4.0 だと古いバージョンを使う

Fody パッケージ自体は、.NET Frameworkのバージョン依存関係はありませんが、Fodyのプラグインである Costura の最新版の方には、

.NET Framework 4.6
Fody (>= 3.2.6)

といった依存関係があります。

ですので、アタッシェケースは、一応WindowsXP上でも動作するように、.NET Frameworkは、“4.0” でビルドするという縛りを設けているため

Fody ver.2.5.0
Costura.Fody ver.1.6.2

と、あえて古いバージョンのパッケージをそれぞれ使っています。

以上です。

iTunes Connect icon

[Electron][MacOS]Your application still accesses the following location(s):でリジェクト



追記:2017/06/18に大幅修正を加えました。

Electronで絶賛、開発中の「OutlineText」ですが、バージョンアップ版を審査に提出したところ、タイトルのようなメッセージとともにリジェクトが。

正確には、

2. 4 Performance: Hardware Compatibility (macOS)
Guideline 2.4.5(i) – Performance

Your application still accesses the following location(s):

‘/Applications/OutlineText.app/Contents/Resources/app/locales/en.json.tmp’

The majority of developers encountering this issue are opening files in Read/Write mode instead of Read-Only mode, in which case it should be changed to Read-Only.

これを初めてもらったとき、「そうか、たしかに自分で自分のappにアクセスしてしまったっらダメだな」と思って、OutlineText.app内のディレクトリにはロックをかけるような仕組みを作り直して、審査に再提出。

ところが、またリジェクト。理由は、判で押したように上記のメッセージで返ってきました。いろいろ試行錯誤した結果、4回連続でリジェクトを頂戴するという羽目に(審査チームももう少しヒントをくれても良いのに・・・)。

そこまでやって、ふと思い当たったのは、ひょっとすると、Entitlementsの指定が誤っているのではないか?ということで、先の記事中では、Electron でビルドするときは、シェルスクリプトでやっていて、以下のように設定しておりました。

Electornは、単体アプリではなくヘルパーアプリも一緒に動くので、codesignは本体同様にすべてに行ってあげないといけません。でないと、審査以前にアプリをアップロードするときに、アップローダーに怒られます。

この辺りは、「Electron アプリを Mac App Store に登録する手順」が参考になるでしょう。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.files.user-selected.read-only</key>
    <true/>
    <key>com.apple.security.application-groups</key>
    <string>ABCD9EFGHI.org.hibara.outlinetext</string>
  </dict>
</plist>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.inherit</key>
    <true/>
  </dict>
</plist>
FRAMEWORKS_PATH="$APP_PATH/Contents/Frameworks"

codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Electron Framework"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libffmpeg.dylib"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework/Versions/A/Libraries/libnode.dylib"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/Electron Framework.framework"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/Contents/MacOS/$APP Helper"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper.app/"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper EH.app/Contents/MacOS/$APP Helper EH"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper EH.app/"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper NP.app/Contents/MacOS/$APP Helper NP"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$FRAMEWORKS_PATH/$APP Helper NP.app/"
codesign -s "$APP_KEY" -f --entitlements "$CHILD_PLIST" "$APP_PATH/Contents/MacOS/$APP"
codesign -s "$APP_KEY" -f --entitlements "$PARENT_PLIST" "$APP_PATH"

ここで、問題になっていたのは、parent.plist 9行目のEntitlementsに、com.apple.security.files.user-selected.read-write が指定されていること。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.files.user-selected.read-only</key>
    <true/>
    <key>com.apple.security.files.user-selected.read-write</key>
    <true/>
    <key>com.apple.security.application-groups</key>
    <string>ABCD9EFGHI.org.hibara.outlinetext</string>
  </dict>
</plist>

このせいでいくらい対策を講じても、アクセスは可能になっていたというわけかと納得。そこで、以下のようにcom.apple.security.files.user-selected.read-onlyだけの記述にして審査へ再提出したら、ようやく審査に通りました。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.files.user-selected.read-only</key>
    <true/>
    <key>com.apple.security.application-groups</key>
    <string>ABCD9EFGHI.org.hibara.outlinetext</string>
  </dict>
</plist>

ところが、いったんは審査を通ったものの、今度は保存ダイアログボックスが出ないというバグが発生・・・(これは審査チームも見逃したようです)。

考えてもみれば、そりゃそうですよね。read-onlyなのですから、保存ダイアログが出ないのは当たり前です。

で、結局は以下のように、指定を元に戻して再提出しました。具体的には、Entitlementsの指定で、read-onlyを、read-writeに書き換えています。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
  <dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.files.user-selected.read-write</key>
    <true/>
    <key>com.apple.security.application-groups</key>
    <string>ABCD9EFGHI.org.hibara.outlinetext</string>
  </dict>
</plist>

これでまた一から振り出しに戻り、先のテンプレ文とリジェクトをいただくことに。。。

そこで業を煮やした僕は、審査チームに「アピール」を送信してみることにしました。

Writes in the location are restricted and be open for read-only already. Please tell me the detailed occurrence procedure.

画像も添付しました。

read-only

ちゃんと「読み取り専用」として開いているぞ、と。

すると、二日後に以下のようなメッセージが届きました。

Hello,

Thank you for your inquiry. To clarify, the app is opening files in Read/Write mode instead of Read-Only mode, in which case it should be changed to Read-Only.
The file being accessed is:
‘/Applications/OutlineText.app/Contents/Resources/app/locales/en.json.tmp’

これを読んで、ふと、思い当たる節がありました。ひょっとして、ファイルの読み書きのところで、意図せずread-writeで読み込んでいる箇所があるのではないか?と。

実際、ありました。

保存するとき(ファイルに書き込むとき)、前に保存したディレクトリが存在しているかどうかチェックをして、なければデフォルトは「書類」フォルダを指定するというコードにしていました。参考にしたのは、「Check synchronously if file/directory exists in Node.js – Stack Overflow」です。

let dirPath = config.get('InitialDirPath');
try {
  // Query the entry
  let stats = fs.lstatSync(dirPath);
  // Is it a directory?
  if (stats.isDirectory()) {
	// Yes it is
  }
}
catch (e) {
  dirPath = app.getPath('documents');
}

ひょっとすると、fs.lstatSyncが、read-writeでアクセスしに行っているのではないか?と思い(Node.jsの公式ページでは確認できなかったのですが)、Stack Overflowのページをもう一度確認してみると、回答が大幅に書き換えられていて、以下が推奨となっていました。

var fs = require('fs');
if (fs.existsSync(path)) {
    // Do something
}

そこで、この部分を上記で書き直して、再度Appleに提出。程なくして、「Ready for sale」の返答があり、ようやくバージョンアップ版がApp Storeに並ぶことになりました。

やれやれ・・・一件落着です。

ちなみに、Appleの審査チームのアピールですが、質問形式で送ると、即座にテンプレで「サポートか、フォーラムに投げてね」的な回答が返ってきますが、「再現の手順」を教えてくれ、といった具体的な質問に対しては、ヒントになるくらいの回答が得られました。アピールの仕方にも、少し工夫が必要ですね。

 

Electron-icon

Electronでメニューへ動的にチェックを入れる



ElectronでMacOSアプリ作っています。

当サイトで「Open source & Free software」などと、標榜しておきながら、MacOSの方で有料アプリとして作って公開しています。(→気になる方はコチラからどうぞ)。

言い訳がましいですが、Appleのディベロッパーアカウントは1万円/年ほどかかっており、フリーで公開中のWindowsアプリのコードサイニング証明書更新など6万円/年と、すでに出銭の方が多いのではないかという状況でして。どうか忖度、願います。

そんなことはさておき、表題の件。

意外とサンプルが見つからなかったんですよね。

アプリ起動時のメニュー生成は、サンプルもいくつかみつかり、比較的簡単です。

let template = [
    {
      label: i18n.__('View'),
      submenu: [
        {
          label: i18n.__('Encoding'),
          id: 'Encoding',
          submenu: [
            {
              label: 'UTF-8',
              type: 'checkbox',
              checked: true
            },
            {
              label: 'EUC-JP',
              type: 'checkbox',
              checked: false
            },
            {
              label: 'Shift_JIS',
              type: 'checkbox',
              checked: false
            }
          ]
        }
      }
    }

  const menu = Menu.buildFromTemplate(template);
  Menu.setApplicationMenu(menu);

JSON形式でテンプレートを作っておいて、それをアプリケーションメニューとしてセットするだけです。

ただ、これですと、セットした後にメニュー内容を変更するにはどうすれば良いのしょうか? たとえば、以下のような場合です。

sample-menu

チェックボックスの変更です。たとえば、上記の例でいえば、エンコーディングの種類の変更を行いたい(その選択したメニューにチェックを入れ直したい)場合です。

これはもう構築したメニュー全体から、変更したい該当のメニューを探し出すしかありません。そこで、前述のソースコードの7行目に注目してほしいのですが、メニューアイテムの属性値に「id」を追加しています。これを頼りに検索する関数をつくります。

function getSubMenuItem(subMenuItems, id) {
  if (subMenuItems) {
    for (let i = 0; i < subMenuItems.length; i++) {
      if (subMenuItems[i].id === id) {
        return subMenuItems[i];
      }
      else if (subMenuItems[i].submenu) {
        let found = getSubMenuItem(subMenuItems[i].submenu.items, id);
        if (found) return found;
      }
    }
  }
}

const menu = Menu.getApplicationMenu();
let encodingMenu = getSubMenuItem(menu.items, 'Encoding');

これにより、変数encodingMenuには、「エンコーディング」以下のサブメニューオブジェクトが入ります。

これも関数にまとめてしまいましょう。

function clickEncodingMenu(item){
  const menu = Menu.getApplicationMenu();
  let encodingMenu = getSubMenuItem(menu.items, 'Encoding');

  // 一度、全チェックを外す
  for(let i = 0; i < encodingMenu.submenu.items.length; i++){
    encodingMenu.submenu.items[i].checked = false;
  }
  // 選択したエンコーディングをチェック
  item.checked = true;
}

ここで注意したいのが、6行目のループで使われているsubmenu.itemsです。ここ、submenuだけにしがちなので、ご注意ください。

まとめるとこうなります。

function getSubMenuItem(subMenuItems, id) {
  if (subMenuItems) {
    for (let i = 0; i < subMenuItems.length; i++) {
      if (subMenuItems[i].id === id) {
        return subMenuItems[i];
      }
      else if (subMenuItems[i].submenu) {
        let found = getSubMenuItem(subMenuItems[i].submenu.items, id);
        if (found) return found;
      }
    }
  }
}

let template = [
{
  label: i18n.__('View'),
  submenu: [
	{
	  label: i18n.__('Encoding'),
	  id: 'Encoding',
	  submenu: [
		{
		  label: 'UTF-8',
		  type: 'checkbox',
		  checked: true,
		  click: function (item) {
			clickEncodingMenu(item);
		  }
		},
		{
		  label: 'EUC-JP',
		  type: 'checkbox',
		  checked: false,
		  click: function (item) {
			clickEncodingMenu(item);
		  }
		},
		{
		  label: 'Shift_JIS',
		  type: 'checkbox',
		  checked: false,
		  click: function (item) {
			clickEncodingMenu(item);
		  }
		}
	  ]
	}
  }
}
const menu = Menu.getApplicationMenu();
let encodingMenu = getSubMenuItem(menu.items, 'Encoding');

本来ならよくあるように、’id’だけでズバッと該当のメニューを指定して変更できるのがベターなのですが、現状ではその方法がないので、メニュー内を探索して特定するしかなさそうですね。

 

「アタッシェケース#3」を正式版としました。


β版リリース時にもブログ記事を書きましたが、バグ報告もなくなり、自身で使っていても、目立った不具合がなくなってきたため、正式版としました。それでも細かいバグはまだまだありそうですので、もし何かあれば報告をいただけるとうれしいです。

アタッシェケース#3アイコンhttps://hibara.org/software/attachecase/

前述の記事でも書きましたが、Ver.2からの変更点のおさらい。

  • ファイルフォーマットの変更(Ver.3独自)
  • パスワードの扱いについての改良(RFC2898によるキー派生)
  • 暗号化、復号の処理速度の向上
  • Windows 10(タッチ操作など)に対応
  • パスワード付きZIPファイルの作成機能(おまけ)

ver.2は、2004年の開発開始からほとんど修正されることのなかったファイルフォーマットに手を入れました。冗長な部分を削除し、やや弱かったパスワード部分の扱いを改良、メモリで扱う部分を大きくし、また高速化(並列処理)に適したフォーマットにしました。

ですので、Ver.3で暗号化されたファイルは、Ver.2では復号できませんので、あらかじめご注意ください。ただし、Ver.2ファイルはVer.3では開けます。つまり上位互換です。

パスワード付きZIPファイルへの対応はおまけです(笑)。知人からの要望を受けて、入れてみました。邪魔で不評なら将来的に削除、好評なら復号処理も入れようかと思います。

技術的な変更点は、

  • .NET Frameworkでの開発
  • コードサイニング証明書の付加

今までC++Builderで開発を行ってきましたが、毎年のバージョンアップ費用がもはや個人ユースとして耐えられなくなってきたのと、無料で使える、Microsoftの「Microsoft Visual Studio Express 2015 for Windows Desktop」にした方が、より多くの人にとって、オープンソースからのプルリクエストや、フォークがしやすいのではないかと思い、乗り換えてみました。

また、暗号化ツールという性質上、セキュリティ面での使用を躊躇してしまうのを少しでも軽減しようと、コードサイニング証明書を付加してみました。法人ではなく僕個人のもので、けっこうなお値段でしたが、少しでも安心して使っていただけるようにと自腹で負担しました(泣)。

より多くの人に使っていただけるのが、開発者としては望外の喜びです。

電卓の16進から10進へ

C#で文字列をバイナリサーチする



前提として、僕のケースでは、『アタッシェケース#3』にて、ファイル先頭から固定値である「_AttacheCaseData」(16バイト)を検索していきます。それにより、自己実行形式ファイルのデータ境界が分かるようになります。

ウェブを検索してみたら、以下のサイトが近い感じがするのですが、

バイナリデータを検索する方法(vb.net)
http://www.my-hobby.jp/index.php/2012/01/vb-net2/

File.ReadAllBytes()で、一気にファイルをバイト単位での読み込みを行っています。僕のアタッシェケース#3では、出力されるファイルが、2GBを余裕で超えてくるファイルも扱う可能性もあるので、それは使えません。

そこで、File.ReadByte()を使います。知ってましたか、ReadByte();

// _AttacheCaseData
//byte[] AtcTokenByte = { 0x5F, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x65, 0x43, 0x61, 0x73, 0x65, 0x44, 0x61, 0x74, 0x61};
int[] AtcTokenByte = { 95, 65, 116, 116, 97, 99, 104, 101, 67, 97, 115, 101, 68, 97, 116, 97};

using (FileStream fs = new FileStream(FilePath, FileMode.Open, FileAccess.Read))
{
  bool fToken = false;
  int b;
  while ((b = fs.ReadByte()) > -1)
  {
    //-----------------------------------
    // Check the token &quot;_AttacheCaseData&quot;
    if (b == AtcTokenByte[0])
    {
      fToken = true;
      for ( int i = 1; i < AtcTokenByte.Length; i++)
      {
        if (fs.ReadByte() != AtcTokenByte[i])
        {
          fToken = false;
          break;
        }
      }
      if ( fToken == true)
      {
        _fExecutableType = true;
        break;
      }
    }

  }// end while();

}

ReadByte()は、ストリームから1バイトずつ読み込んで行きます。ただし、返値がバイトではなく、Int32 にキャストされた符号なしバイト(int)で返ってくるのに要注意。

あらかじめ分かっている定数ならば、僕のようにint配列にしますが、場合によっては、byte値をその度にintにキャストして比較しても良いでしょう。

電卓の16進から10進へ

ちなみにbyte値をintにするには、Windowsの電卓を「プログラマ」にして「16進」→数値入力→「10進」にして、値を出しました。16進を10進に脳内変換で出来ちゃうプログラマーさんはすごいと思う(常識デスカ?)。

s