サーバー管理などの定型操作を行うためのシステムトレイ常駐ツールを考える。

名称
Kagaribi とする。
トレイからコマンドを起動 → トレイ・起動 → 盆・fire → Bonfire → 篝火 → Kagaribi という流れ。最初は Bonfire にしようとしたけど、Flutter 向けのフレームワークに Bonfire という有名なものがあるそうなので。
ざっくりの仕様
何か (例えばサーバー) の一覧をYAML形式で出力できるコマンドがあるとする。
ダミーの例としてはこんな感じ。
@echo off echo - name: Server1 echo ip: 192.168.1.100 echo status: running echo - name: Server2 echo ip: 192.168.1.101 echo status: stopped echo - name: Server3 echo ip: 192.168.1.102 echo status: running echo - name: Database echo ip: 192.168.1.200 echo status: running echo - name: WebServer echo ip: 192.168.1.10 echo status: running
これに対して、こんな感じで config.yaml を用意して、kagaribi.exe と同じフォルダに置く。
app_name: "サーバー管理ツール" command_list: "xxx.bat" timeout_seconds: 30 icon_path: "kagaribi.png" log_path: "" display_field: "name" actions: - name: "SSH接続" command: "ssh {ip}" - name: "Ping確認" command: "ping {ip}" - name: "詳細情報" command: "echo サーバー: {name}, IP: {ip}, 状態: {status}"
アイコンのファイルも同じ場所に置く (以下の例では、Windows の絵文字を PNG 画像にしたものを仮で使っている)。
そして kagaribi.exe を起動すると、システムトレイにアイコンが常駐する。

クリックすると config.yaml の command_list で指定したコマンドが実行され、その出力に応じて一覧が表示される。サブメニューには config.yaml の actions で指定したアクションが表示され、選択するだけで実行できる。

サーバーのリストをイメージして説明したけど、実際には何のリストでもよくて、config.yaml を挿げ替えるだけで様々な用途に使える汎用のツールだ。
システム要件
Windows 11。開発ツールやライブラリの追加インストールは前提としない。
Windowsには.NET Frameworkのコンパイラが最初から入っているので、バージョンは古いけれど、何も入れなくてもC#のコンパイルくらいだったらできる。
Windows 10 でも同じことができるはずだけど、手元に Windows 11 しかないし、サポートも切れるので、10 は考えないことにする。
作りかけのソース
少し前に、入浴中に ChatGPT にコードを書いてもらってたりしてたのを、Cursor に引き継いでもらった。
綺麗なコードにはまだ遠いし、ログ周りの動作確認はまだだし、コマンドの実行についても改善の余地がありまくるけど、ひとまず動くところまでは行った。これをバージョン 0.0.1.0 としよう。
C# ソースコード (クリックで展開)
using System;
using System.Diagnostics;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using System.Collections.Generic;
class TrayApp : ApplicationContext
{
private NotifyIcon trayIcon;
private Config config;
private string logPath;
private CancellationTokenSource _cts = new CancellationTokenSource();
public TrayApp(string configPath)
{
config = LoadConfig(configPath);
logPath = string.IsNullOrWhiteSpace(config.LogPath)
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory)
: config.LogPath;
try
{
trayIcon = new NotifyIcon()
{
Icon = LoadIconSafely(config.IconPath),
Visible = true,
Text = string.IsNullOrWhiteSpace(config.AppName) ? "Tool" : config.AppName
};
}
catch (Exception ex)
{
Console.WriteLine("NotifyIconの作成に失敗しました: " + ex.Message);
// アイコンなしでNotifyIconを作成
trayIcon = new NotifyIcon()
{
Visible = true,
Text = string.IsNullOrWhiteSpace(config.AppName) ? "Tool" : config.AppName
};
}
trayIcon.MouseClick += TrayIcon_MouseClick;
// アプリケーション終了時にキャンセル通知
Application.ApplicationExit += (s, e) => _cts.Cancel();
}
private System.Drawing.Icon LoadIconSafely(string iconPath)
{
try
{
// ファイルの拡張子を確認
string extension = Path.GetExtension(iconPath).ToLower();
if (extension == ".png")
{
// PNGファイルの場合、Bitmapからアイコンを作成
var bitmap = new System.Drawing.Bitmap(iconPath);
try
{
// アイコンサイズ(16x16、32x32、48x48)でアイコンを作成
return System.Drawing.Icon.FromHandle(bitmap.GetHicon());
}
catch
{
// FromHandleが失敗した場合、Bitmapを直接使用
bitmap.Dispose();
throw;
}
}
else if (extension == ".ico")
{
// ICOファイルの場合は直接読み込み
return new System.Drawing.Icon(iconPath);
}
else
{
// その他の場合はBitmapとして読み込みを試行
var bitmap = new System.Drawing.Bitmap(iconPath);
try
{
return System.Drawing.Icon.FromHandle(bitmap.GetHicon());
}
catch
{
// FromHandleが失敗した場合、Bitmapを直接使用
bitmap.Dispose();
throw;
}
}
}
catch (Exception ex)
{
Console.WriteLine("アイコンの読み込みに失敗しました: " + iconPath + " - " + ex.Message);
// デフォルトアイコンを返す
try
{
// システムの標準アイコンを使用
return System.Drawing.SystemIcons.Application;
}
catch
{
// 最後の手段として、デフォルトアイコンを返す
return System.Drawing.SystemIcons.Application;
}
}
}
private async void TrayIcon_MouseClick(object sender, MouseEventArgs e)
{
if (e.Button != MouseButtons.Left) return;
ContextMenuStrip menu = new ContextMenuStrip();
string lastStdOut = "";
string lastStdErr = "";
string errorMessage = null;
try
{
var result = await Task.Run(() =>
RunCommandWithTimeout(config.CommandList, config.TimeoutSeconds * 1000, _cts.Token),
_cts.Token
);
lastStdOut = result.StdOut;
lastStdErr = result.StdErr;
if (string.IsNullOrWhiteSpace(lastStdOut))
{
errorMessage = "エラー: 出力がありません";
}
else
{
var servers = ParseYaml<List<Dictionary<string, object>>>(lastStdOut);
foreach (var server in servers)
{
string display = server.ContainsKey(config.DisplayField)
? server[config.DisplayField].ToString()
: "(unknown)";
var serverItem = new ToolStripMenuItem(display);
foreach (var action in config.Actions)
{
var actionItem = new ToolStripMenuItem(action.Name);
actionItem.Click += (s, ev) =>
{
string cmd = ReplacePlaceholders(action.Command, server);
Console.WriteLine("実行するコマンド: " + cmd);
RunCommand(cmd); // 同期実行でOK
};
serverItem.DropDownItems.Add(actionItem);
}
menu.Items.Add(serverItem);
}
}
}
catch (OperationCanceledException)
{
errorMessage = "エラー: コマンドがキャンセルされました";
}
catch (TimeoutException)
{
errorMessage = "エラー: タイムアウトしました";
}
catch (Exception ex)
{
errorMessage = "エラー: " + ex.Message;
}
if (errorMessage != null)
{
AddErrorItem(menu, errorMessage, lastStdErr, lastStdOut);
}
// 共通メニュー項目
menu.Items.Add(new ToolStripSeparator());
var aboutItem = new ToolStripMenuItem("バージョン情報");
aboutItem.Click += (s, ev) =>
{
string version = FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion;
MessageBox.Show(string.Format("{0}\nバージョン: {1}", config.AppName, version), "バージョン情報",
MessageBoxButtons.OK, MessageBoxIcon.Information);
};
menu.Items.Add(aboutItem);
var exitItem = new ToolStripMenuItem("終了");
exitItem.Click += (s, ev) => Application.Exit();
menu.Items.Add(exitItem);
trayIcon.ContextMenuStrip = menu;
menu.Show(Cursor.Position);
}
private void AddErrorItem(ContextMenuStrip menu, string message, string stderr, string stdout)
{
var errItem = new ToolStripMenuItem(message)
{
Enabled = true,
ToolTipText = "クリックするとログを開きます"
};
string timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string fileName = string.Format("error_{0}.log", timestamp);
string filePath = Path.Combine(logPath, fileName);
string logEntry = string.Format(
@"=== [{0}] ===
Message: {1}
--- STDOUT ---
{2}
--- STDERR ---
{3}
",
DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"),
message,
string.IsNullOrWhiteSpace(stdout) ? "[No stdout output]" : stdout,
string.IsNullOrWhiteSpace(stderr) ? "[No stderr output]" : stderr
);
Directory.CreateDirectory(Path.GetDirectoryName(filePath));
File.WriteAllText(filePath, logEntry);
errItem.Click += (s, ev) => Process.Start("notepad.exe", filePath);
menu.Items.Add(errItem);
}
private CommandResult RunCommandWithTimeout(string cmd, int timeoutMs, CancellationToken token)
{
using (var p = new Process())
{
p.StartInfo = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = string.Format("/c {0}", cmd),
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
p.Start();
Task<bool> waitTask = Task.Run(() => p.WaitForExit(timeoutMs), token);
if (!waitTask.Result)
{
try { p.Kill(); } catch { }
throw new TimeoutException();
}
string stdout = p.StandardOutput.ReadToEnd();
string stderr = p.StandardError.ReadToEnd();
return new CommandResult { StdOut = stdout, StdErr = stderr };
}
}
private void RunCommand(string cmd)
{
// アクション用の同期実行。デバッグ用にウィンドウを表示
try
{
using (var p = new Process())
{
p.StartInfo = new ProcessStartInfo
{
FileName = "cmd.exe",
Arguments = string.Format("/c {0}", cmd),
UseShellExecute = false,
CreateNoWindow = false // ウィンドウを表示
};
p.Start();
// コマンドの実行結果を確認できるように少し待機
p.WaitForExit(5000); // 5秒でタイムアウト
}
}
catch (Exception ex)
{
// エラーが発生した場合はメッセージボックスで表示
MessageBox.Show(string.Format("コマンド実行エラー:\n{0}\n\n{1}", cmd, ex.Message),
"エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private string ReplacePlaceholders(string command, Dictionary<string, object> server)
{
string result = command;
foreach (var kvp in server)
{
result = result.Replace("{" + kvp.Key + "}", kvp.Value.ToString());
}
return result;
}
private T ParseYaml<T>(string yaml)
{
// 簡易的なYAMLパーサー(基本的なキー: 値形式に対応)
if (typeof(T) == typeof(Config))
{
return (T)(object)ParseConfigFromYaml(yaml);
}
else if (typeof(T) == typeof(List<Dictionary<string, object>>))
{
return (T)(object)ParseListFromYaml(yaml);
}
throw new NotSupportedException("サポートされていない型です: " + typeof(T).Name);
}
private Config ParseConfigFromYaml(string yaml)
{
var config = new Config();
var lines = yaml.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
var actions = new List<ActionConfig>();
bool inActions = false;
ActionConfig currentAction = null;
foreach (var line in lines)
{
var trimmedLine = line.Trim();
if (trimmedLine.StartsWith("#") || string.IsNullOrWhiteSpace(trimmedLine))
continue;
if (trimmedLine.StartsWith("-"))
{
// 新しいアクションの開始
if (currentAction != null)
actions.Add(currentAction);
currentAction = new ActionConfig();
inActions = true;
// "-" の後の内容を解析(例: "- name: SSH接続")
string content = trimmedLine.Substring(1).Trim();
if (!string.IsNullOrWhiteSpace(content))
{
ParseActionContent(content, currentAction);
}
}
else if (inActions && currentAction != null)
{
var kvp = ParseKeyValue(trimmedLine);
if (!string.IsNullOrWhiteSpace(kvp.Key))
{
ParseActionProperty(kvp.Key, kvp.Value, currentAction);
}
}
else
{
var kvp = ParseKeyValue(trimmedLine);
if (!string.IsNullOrWhiteSpace(kvp.Key))
{
ParseConfigProperty(kvp.Key, kvp.Value, config);
}
}
}
// 最後のアクションを追加
if (currentAction != null)
actions.Add(currentAction);
// デフォルト値の設定
if (string.IsNullOrWhiteSpace(config.AppName))
config.AppName = "Tray Tool";
if (string.IsNullOrWhiteSpace(config.CommandList))
config.CommandList = "echo test";
if (config.TimeoutSeconds <= 0)
config.TimeoutSeconds = 30;
if (string.IsNullOrWhiteSpace(config.IconPath))
config.IconPath = "C:\\Windows\\System32\\shell32.dll";
if (string.IsNullOrWhiteSpace(config.DisplayField))
config.DisplayField = "name";
// Actionsの設定
if (actions.Count > 0)
{
config.Actions = actions;
}
else if (config.Actions == null)
{
// Actionsが設定されていない場合のデフォルト値
config.Actions = new List<ActionConfig>
{
new ActionConfig { Name = "アクション1", Command = "echo {name}" }
};
}
return config;
}
private List<Dictionary<string, object>> ParseListFromYaml(string yaml)
{
var result = new List<Dictionary<string, object>>();
var lines = yaml.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
Dictionary<string, object> currentItem = null;
foreach (var line in lines)
{
var trimmedLine = line.Trim();
if (trimmedLine.StartsWith("#") || string.IsNullOrWhiteSpace(trimmedLine))
continue;
if (trimmedLine.StartsWith("-"))
{
// 新しいアイテムの開始
if (currentItem != null)
result.Add(currentItem);
currentItem = new Dictionary<string, object>();
// "-" の後の内容を解析(例: "- name: Server1, ip: 192.168.1.100, status: running")
string content = trimmedLine.Substring(1).Trim();
if (!string.IsNullOrWhiteSpace(content))
{
ParseKeyValuePairs(content, currentItem);
}
}
else if (currentItem != null)
{
var kvp = ParseKeyValue(trimmedLine);
if (!string.IsNullOrWhiteSpace(kvp.Key))
{
currentItem[kvp.Key] = kvp.Value;
}
}
}
// 最後のアイテムを追加
if (currentItem != null)
result.Add(currentItem);
return result;
}
private void ParseKeyValuePairs(string content, Dictionary<string, object> item)
{
// カンマで区切られたキー:値のペアを解析
var pairs = content.Split(',');
foreach (var pair in pairs)
{
var kvp = ParseKeyValue(pair);
if (!string.IsNullOrWhiteSpace(kvp.Key))
{
item[kvp.Key] = kvp.Value;
}
}
}
private void ParseActionContent(string content, ActionConfig action)
{
// キー:値のペアを解析(例: "name: SSH接続")
var kvp = ParseKeyValue(content);
if (!string.IsNullOrWhiteSpace(kvp.Key))
{
ParseActionProperty(kvp.Key, kvp.Value, action);
}
}
private KeyValuePair<string, string> ParseKeyValue(string content)
{
var colonIndex = content.IndexOf(':');
if (colonIndex > 0)
{
var key = content.Substring(0, colonIndex).Trim();
var value = content.Substring(colonIndex + 1).Trim();
// クォートを除去
value = RemoveQuotes(value);
return new KeyValuePair<string, string>(key, value);
}
return new KeyValuePair<string, string>(null, null);
}
private string RemoveQuotes(string value)
{
if (string.IsNullOrWhiteSpace(value)) return value;
// クォートを除去
if (value.StartsWith("\"") && value.EndsWith("\""))
return value.Substring(1, value.Length - 2);
else if (value.StartsWith("'") && value.EndsWith("'"))
return value.Substring(1, value.Length - 2);
return value;
}
private void ParseActionProperty(string key, string value, ActionConfig action)
{
switch (key.ToLower())
{
case "name":
action.Name = value;
break;
case "command":
action.Command = value;
break;
}
}
private void ParseConfigProperty(string key, string value, Config config)
{
switch (key.ToLower())
{
case "app_name":
config.AppName = value;
break;
case "command_list":
config.CommandList = value;
break;
case "timeout_seconds":
int timeout;
if (int.TryParse(value, out timeout))
config.TimeoutSeconds = timeout;
break;
case "icon_path":
config.IconPath = value;
break;
case "log_path":
config.LogPath = value;
break;
case "display_field":
config.DisplayField = value;
break;
}
}
private Config LoadConfig(string path)
{
try
{
string yaml = File.ReadAllText(path);
return ParseYaml<Config>(yaml);
}
catch (Exception ex)
{
MessageBox.Show(string.Format("設定ファイルの読み込みに失敗しました:\n{0}\n\n{1}",
path, ex.Message),
"エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
Environment.Exit(1);
return null;
}
}
public static void Main(string[] args)
{
string configPath = args.Length > 0
? args[0]
: Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.yaml");
if (!File.Exists(configPath))
{
MessageBox.Show(string.Format("設定ファイルが見つかりません:\n{0}",
configPath),
"エラー", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
Application.Run(new TrayApp(configPath));
}
public class Config
{
public string AppName { get; set; }
public string CommandList { get; set; }
public int TimeoutSeconds { get; set; }
public string IconPath { get; set; }
public string LogPath { get; set; }
public string DisplayField { get; set; }
public List<ActionConfig> Actions { get; set; }
}
public class ActionConfig
{
public string Name { get; set; }
public string Command { get; set; }
}
public class CommandResult
{
public string StdOut { get; set; }
public string StdErr { get; set; }
}
}
これをコンパイルするためのバッチファイルも Cursor に作ってもらった。これもまた動きがおかしい個所がまだある。
最終的には /target:winexe にするんだけど、今の段階では、サブメニューから実行したコマンドの出力がコンソールに出た方が確認しやすいので /target:exe にしている。
compile.bat (クリックで展開)
@echo off
echo Starting C# GUI application compilation...
REM Set path to .NET Framework csc.exe
set CSC_PATH=%WINDIR%\Microsoft.NET\Framework64\v4.0.30319\csc.exe
REM Use 32-bit .NET Framework if 64-bit version doesn't exist
if not exist "%CSC_PATH%" (
set CSC_PATH=%WINDIR%\Microsoft.NET\Framework\v4.0.30319\csc.exe
)
REM Use older version if newer doesn't exist
if not exist "%CSC_PATH%" (
set CSC_PATH=%WINDIR%\Microsoft.NET\Framework\v3.5\csc.exe
)
if not exist "%CSC_PATH%" (
echo Error: .NET Framework csc.exe not found.
echo Please check the following paths:
echo %WINDIR%\Microsoft.NET\Framework64\v4.0.30319\
echo %WINDIR%\Microsoft.NET\Framework\v4.0.30319\
echo %WINDIR%\Microsoft.NET\Framework\v3.5\
pause
exit /b 1
)
echo Compiler path: %CSC_PATH%
REM Execute compilation (changed to /target:exe for debugging)
echo Compiling...
"%CSC_PATH%" /target:exe /out:Kagaribi.exe /reference:System.dll /reference:System.Windows.Forms.dll /reference:System.Drawing.dll /reference:System.Core.dll src\MainForm.cs
if %ERRORLEVEL% EQU 0 (
echo Compilation completed successfully!
echo Executable: Kagaribi.exe
echo.
echo Compiled as console application for debugging.
echo If errors occur, details will be displayed in the console.
echo.
echo Run the application? (Y/N)
set /p choice=
if /i "%choice%"=="Y" (
echo Starting application...
Kagaribi.exe
)
) else (
echo Compilation error occurred.
pause
)
pause