Kagaribi: カスタマイズ前提のシステムトレイ常駐ツール

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

名称

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.yamlcommand_list で指定したコマンドが実行され、その出力に応じて一覧が表示される。サブメニューには config.yamlactions で指定したアクションが表示され、選択するだけで実行できる。

サーバーのリストをイメージして説明したけど、実際には何のリストでもよくて、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