X11: NetWM (5) - StartupNotify

StartupNotify
ファイラなどが、デスクトップファイル (*.desktop) によって関連付けられたアプリを起動する際に、デスクトップファイルの「StartupNotify」項目が true だった場合 (デフォルトで true)、そのアプリを起動したアプリ側は、「アプリが実際に起動してウィンドウが表示された」という通知が送られてくるまで待ちます。

この間、起動を待っているアプリによって、マウスカーソルが待ち状態の形状になる場合があります。

起動されたアプリ側が、起動元に対して完了を通知するには、XSendEvent() で ClientMessage を送信する必要があります。

※起動元のアプリが StartupNotify に対応しているかどうかは、アプリによります。
pcmanfm-qt では対応していませんが、thunar では対応しています。

起動される側のアプリケーションが StartupNotify に対応しない場合は、デスクトップファイルで明示的に StartupNotify=false を指定する必要があります。
デスクトップファイルの例
[Desktop Entry]
Type=Application
Name=app_name
Exec=exe %f
Icon=app_icon
Terminal=false
Categories=Graphics;
MimeType=image/png;
StartupNotify=true

デスクトップファイルは、以下のディレクトリに置かれています。

~/.local/share/applications
/usr/share/applications
/usr/local/share/applications

主に、アプリケーションメニュー用の定義として扱われますが、ファイルタイプごとの関連付けも定義されています。
グループリーダーについて
StartupNotify とも関連してくるので、先に、ICCCM 仕様における、グループリーダーについて説明しておきます。

一つのクライアントで、複数のウィンドウを作成して管理する場合、グループリーダーとなるウィンドウを別に1つ作成して、それをグループリーダーとして設定すると、アプリケーションの複数のウィンドウを、一つのグループとして扱うことができます。

例えば、タスクバーでは、一つのグループに属するウィンドウを、一つのボタンでまとめることができます (タスクバーの設定で、個別のウィンドウとして扱うか、グループとして扱うかを変更できる場合もあります)。

グループリーダーウィンドウは、他にも色々な使い道があるので、基本的には、1つのアプリケーションに対して、常に1つのグループリーダーを作成しておくと良いでしょう。
グループリーダーウィンドウの作成
グループリーダーウィンドウは、画面に出力されず、プロパティなどで設定を行うだけのウィンドウなので、ウィンドウクラスは InputOnly にします。

leader = XCreateWindow(disp, root_window,
    0, 0, 1, 1, 0, CopyFromParent, InputOnly, CopyFromParent,
    0, NULL);

基本的にはイベントも処理しないので、属性はすべてデフォルトで構いません。
グループリーダーを指定
アプリケーションの通常のウィンドウに対しては、グループリーダーのウィンドウを指定する必要があります。

WM_HITS プロパティで、window_group をグループリーダーウィンドウの ID にセットして、設定します。
通知の取得と応答
StartupNotify の解説に戻ります。
通知 ID の文字列を取得
ファイラなどによって、デスクトップファイルからアプリの起動が行われ、StartupNotify が true の場合、環境変数 DESKTOP_STARTUP_ID に、通知用の一意の文字列がセットされます。

例: "pcmanfm-834-arch-leafpad-85_TIME11126003"

起動されたアプリ側が StartupNotify に対応している場合は、<stdlib.h> getenv 関数で、この環境変数の文字列を取得します。
環境変数が存在しない場合は、起動元が StartupNotify に対応していないということです。

環境変数の文字列を取得した後は、その文字列を確保したメモリにコピーし、unsetenv 関数を使って、すぐに環境変数を削除することが推奨されます。
グループリーダーにプロパティをセット
アプリケーションにグループリーダーウィンドウが存在する場合、グループリーダーウィンドウの _NET_STARTUP_ID プロパティに、取得した環境変数の文字列をセットします。
(type = "UTF8_STRING", format = 8)

これによって、グループ内のすべてのウィンドウに、通知 ID が適用されます。
起動完了
アプリケーション側の準備が完了して、ウィンドウが表示された時 (MapNotify イベントが来た時) に、XSendEvent 関数で ClientMessage イベントを送信します。

送るデータ
データで送る値は、「remove: ID=<startup_id>」という形式の UTF-8 文字列です。
上記の例の場合は、以下のような文字列になります。

remove: ID="pcmanfm-834-arch-leafpad-85_TIME11126003"

  • 引用符 (") で囲まれている場合、空白はそのまま記述できます。
  • 通常文字として <"> <\> が含まれる場合、前にバックスラッシュ(\) を付けて、エスケープする必要があります。

文字列の送り方
ClientMessage イベントは、基本的に数値を送るためのものなので、可変バイトの文字列を送る場合は、少し工夫が必要になります。

ClientMessage イベントは最大で 20 byte のデータを送れるため、この部分に文字列をセットして、20 byte ごとにイベントを送信します。
データにヌル文字が含まれる場合は、文字列の終了となります。

message_type には、最初にデータを送る時は _NET_STARTUP_INFO_BEGIN アトム、続けてそれ以降のデータを送る時は _NET_STARTUP_INFO アトムをセットします。

送信
イベントを送る前に、一意なメッセージを識別するためのウィンドウを、別途作成する必要があります。

このウィンドウは、ClientMessage を送信する間だけ必要です。
マップする必要はなく、作成したウィンドウ ID だけを使います。

XClientMessageEvent 構造体の window に、作成したウィンドウ ID を指定します。

XSendEvent 関数は、以下のように呼び出します。

XSendEvent(disp, root_window, False, PropertyChangeMask, &ev);

PropertyChangeMask を選択しているすべてのクライアントに対して送られるので、対象はかなり多くなります。
実際のところ、このイベントを受け取るべきなのは、ファイラなどの、アプリの起動完了を待機しているクライアントなので、そのクライアントを明確に選択する方法がない以上、多くのクライアントに網を掛けるしかありません。

起動元のクライアントがこのイベントを受け取った場合、送られた文字列を元に ID を識別して、待機処理を完了させます。

XSendEvent で送信し終わった後は、作成したウィンドウを破棄します。
プログラム
通常のウィンドウを2つ作成して、グループリーダーを1つ作成します。
DESKTOP_STARTUP_ID 環境変数が存在する場合、完了通知を送ります。

※普通に起動しても、何も起こりません。

どちらかのウィンドウの閉じるボタンで終了します。

$ cc -o run d12-startup.c util.c -lX11

<d12-startup.c>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <X11/Xlib.h>
#include <X11/Xatom.h>
#include <X11/Xutil.h>
#include "util.h"

/* 送る文字列の作成
 *
 * ※<"><\> が含まれていないものとして扱う。
 * ret_len: ヌル文字も含む長さ */

static char *_get_notify_string(char *notify_id,int *ret_len)
{
    char *buf;
    int len;

    len = strlen(notify_id);

    buf = (char *)malloc(len + 14);

    memcpy(buf, "remove: ID=\"", 12);
    memcpy(buf + 12, notify_id, len);
    buf[12 + len] = '"';
    buf[13 + len] = 0;

    *ret_len = len + 14;

    return buf;
}

/* 完了通知を送る */

static void _send_notify(Display *disp,char *notify_id)
{
    Window win;
    XClientMessageEvent ev;
    int len,pos,size;
    char *str;

    str = _get_notify_string(notify_id, &len);

    printf("[send] %s\n", str);

    win = XCreateWindow(disp, g_disp.root,
         0, 0, 1, 1, 0,
         CopyFromParent, InputOnly, CopyFromParent,
         0, NULL);

    //イベント

    memset(&ev, 0, sizeof(XClientMessageEvent));

    ev.type = ClientMessage;
    ev.message_type = GET_ATOM("_NET_STARTUP_INFO_BEGIN");
    ev.display = disp;
    ev.window = win;
    ev.format = 8;

    //送信

    pos = 0;

    while(len > 0)
    {
        size = (len < 20)? len: 20;
        
        memcpy(ev.data.b, str + pos, size);

        if(size < 20)
            memset(ev.data.b + size, 0, 20 - size);

        XSendEvent(disp, g_disp.root, False, PropertyChangeMask, (XEvent *)&ev);

        if(pos == 0)
            ev.message_type = GET_ATOM("_NET_STARTUP_INFO");

        pos += size;
        len -= size;
    }

    //終了

    XDestroyWindow(disp, win);
    XFlush(disp);
}

int main(int argc,char **argv)
{
    Display *disp;
    Window win[2],leader;
    XEvent ev;
    XWMHints h;
    char *notify_id;
    
    disp = XOpenDisplay(NULL);
    if(!disp) return 1;

    set_display(disp);

    //環境変数の取得

    notify_id = getenv("DESKTOP_STARTUP_ID");

    if(!notify_id)
        printf("DESKTOP_STARTUP_ID: <none>\n");
    else
    {
        notify_id = strdup(notify_id);

        printf("DESKTOP_STARTUP_ID: '%s'\n", notify_id);
    }

    unsetenv("DESKTOP_STARTUP_ID");

    //グループリーダー

    leader = XCreateWindow(disp, g_disp.root,
        0, 0, 1, 1, 0,
        CopyFromParent, InputOnly, CopyFromParent,
        0, NULL);

    if(notify_id)
    {
        XChangeProperty(disp, leader,
            GET_ATOM("_NET_STARTUP_ID"), GET_ATOM("UTF8_STRING"), 8,
            PropModeReplace, (unsigned char *)notify_id, strlen(notify_id));
    }

    //通常ウィンドウ

    win[0] = create_test_window2(disp, 200, 200, 0, StructureNotifyMask);

    win[1] = create_test_window2(disp, 200, 200,
        rgb_to_pixel(0x0000ff), StructureNotifyMask);

    //グループリーダーをセット

    h.flags = WindowGroupHint;
    h.window_group = leader;

    XSetWMHints(disp, win[0], &h);
    XSetWMHints(disp, win[1], &h);

    //イベント

    XMapWindow(disp, win[0]);
    XMapWindow(disp, win[1]);

    while(1)
    {
        XNextEvent(disp, &ev);

        if(event_quit(&ev)) break;

        switch(ev.type)
        {
            case MapNotify:
                //最初のマップ時に通知
                if(notify_id)
                {
                    _send_notify(disp, notify_id);
                    
                    free(notify_id);
                    notify_id = NULL;
                }
                break;
        }
    }

    XCloseDisplay(disp);

    if(notify_id) free(notify_id);

    return 0;
}
デスクトップファイル
StartupNotify を確認するためには、デスクトップファイルからアプリを起動する必要があります。
普通にアプリを起動しても、何も起こりません。

以下は、アプリをテキストファイルに関連付けるデスクトップファイルです。
Exec= の部分に、このプログラムの実行ファイルの絶対パスを指定してください。

<startup_test.desktop>
[Desktop Entry]
Type=Application
Name=startup_test
Exec=/path/run
Icon=terminal
Terminal=true
Categories=Graphics;
MimeType=text/plain;
StartupNotify=true

このファイルを ~/.local/share/applications にコピーした後、update-desktop-database コマンドでキャッシュを更新します。

$ cp startup_test.desktop ~/.local/share/applications
$ update-desktop-database ~/.local/share/applications

その後、thunar などのファイラで、適用なテキストファイルを右クリックして、「startup_test」でアプリを起動してください。
同時に端末も表示されるので、出力されたテキストを確認できます。

※ファイラ上で「startup_test」が表示されない場合は、ファイラを再起動してください。

起動時に DESKTOP_STARTUP_ID 環境変数が設定されていない場合は、ファイラが StartupNotify に対応していません。
デスクトップファイルの削除
アプリの動作が確認できたら、デスクトップファイルを削除して、キャッシュを更新してください。

$ rm ~/.local/share/applications/startup_test.desktop
$ update-desktop-database ~/.local/share/applications