• 作成:
  • 更新:

Windowsの仕様に苦しみながらWindowsのKeyhacにXMonadのrunOrRaiseを移植しました

リポジトリにあるソースコードはこちら。 keyhac-config/config.py at master · ncaq/keyhac-config

やりたいこと

Linux向けの高度にカスタマイズできるウィンドウマネージャである、 XMonad向けの関数である、 runOrRaise があります。

これをWindows向けの、 Keyhac - Pythonによる柔軟なキーカスタマイズツールに移植したいです。

runOrRaiseは何をする関数なのか?

raise関数

まずraise関数というものがあります。

raiseに適切なクエリを渡したものをキーに設定すると、例えばホームポジションでWin+右手人差し指を押すとFirefoxにフォーカスして、ホームポジションでWin+右手薬指を押すとEmacsにフォーカスするという動作が可能です。

これを設定しまくれば、デュアルモニタでウィンドウを出し分けている場合で、一々マウスやトラックボールに手を伸ばして目的のウィンドウをクリックしてフォーカスを移る必要はありません。

同一モニタに位置するウィンドウをフォーカスし直すのに、タスクバーまでポインターを移動させてクリックする必要もありません。

Alt+Tabのようにウィンドウ一覧を出して、小さい目的のウィンドウを頑張って探して決定するという操作も不要です。

小さく指を動かすだけでいつも使っているアプリケーションなら高速にフォーカスを切替可能です。

runOrRaise関数

raise関数でフォーカスしたのに失敗した場合に、そのソフトウェアを立ち上げます。

つまりFirefoxにフォーカスしたいと思って右手人差し指のキーを押した時に、起動していなければ自動で起動してくれます。

X11/GNU/Linux環境ならばシェルスクリプトで簡単に再現可能

XMonad以外でも例えばGnome Shellなどでも、

#!/bin/zsh

if `wmctrl -xa $1`
then
else
    exec $@
fi

run-or-raise/run-or-raise at master · ncaq/run-or-raise

のように、 wmctrlコマンドを使えば、こういうシェルスクリプトをショートカットに引数付きで登録することで容易に実現可能でした。

KDEには組み込みで存在するらしいですね。 KDE使わなくなってから知ったので設定したことは無いですが。

Keyhacにもraise相当なら存在します

Keyhac標準でも、 ActivateWindowCommandraise相当の機能を提供してくれます。

それでは次第に満足できなくなった

最初はスタートアップにいつも使うアプリ登録しておけばまあ良いかと思ってraise相当だけで良いかと思っていたのですが、再起動を繰り返してシステムを整えたりする時も起動してくるのは鬱陶しいなと思うようになってきました。

また、 Amazon Musicとか何故かスタートアップに入れても起動しないものもありますし、 KeePassXCとかは使うときだけ起動したいです。

WSLg絡みは特に重いので使うときだけ起動したい。これは特にシェルスクリプト経由の起動をするときなどはタスクバーにピン留めも出来ませんし。

スタートメニューにプログラム名入れて起動するのは面倒。

よってキーを押すだけで起動できるrunOrRaiseが欲しいです。

runOrRaiseNextは難しいので今回は考えない

実際に私がXMonadで使ってるのは、 runOrRaiseNext です。

これは同じウィンドウが複数存在すれば、キーを複数回押すことで条件に当てはまるウィンドウだけを順番に表示してくれるものです。

例えばEvinceで複数のPDFを開いているときなどに便利です。

しかし、これは現在のウィンドウの位置とかウィンドウ全体の順序付けとか考えるのが面倒なので、とりあえず実装しないことにします。

最初の実装

とりあえず雑に実装してみたものがこれです。

def run_or_raise(
    keymap: Any,
    exe_name: Optional[str] = None,
    class_name: Optional[str] = None,
    window_text: Optional[str] = None,
    check_func: Optional[Callable[[Any], bool]] = None,
    force: bool = False,
) -> Callable[[], Any]:
    """
    XMonadの
    [runOrRaise](https://hackage.haskell.org/package/xmonad-contrib-0.17.1/docs/XMonad-Actions-WindowGo.html#v:runOrRaise)
    をKeyhacに移植する。
    既にプロセスやウィンドウが存在すればそれにフォーカスして、
    存在しなければ起動する。
    Keyhacの型情報を適切に参照するのが難しいため型定義に`Any`がしばしば入る。
    """

    def inner() -> Any:
        """機能を提供する関数を返す必要があるので内部で関数を生成する。"""
        isActive = keymap.ActivateWindowCommand(
            exe_name, class_name, window_text, check_func, force
        )()
        if isActive != None:
            # ウィンドウが見つかった場合一応その値を返す。
            return isActive
        else:
            # ウインドウが見つからなかった場合、起動する。
            keymap.ShellExecuteCommand(None, exe_name, "", "")()
            return isActive

    return inner

keymap.ActivateWindowCommandはフォーカスできたかの情報を返すので、これを内部で実行して分岐してやれば分岐自体は出来ます。

しかし、 keymap.ShellExecuteCommandに渡すべき引数が曲者で、 Windowsは実行可能ファイルのPATHが全然統一されていないし、プログラムへのパスが通ってないことがままある(これは統一されてないのでPATH名の最長値を考えると仕方がない点もある)ので、適当にexe_nameを渡すだけだと実行できないプログラムが沢山あります。そもそもWSLgへのリンクとかexeですらありませんし。

PATHが通ってる一部のプログラムの位置が分からなくてwheretype相当のコマンドを少し時間かけて探しました。 gcmコマンドを使えば良いようです。

PS C:\Users\ncaq> gcm slack

CommandType     Name                                               Version    Source
-----------     ----                                               -------    ------
Application     Slack.exe                                          0.0.0.0    C:\Users\ncaq\AppData\Local\Microsoft\WindowsApps\Slack.exe

どうやってコマンドを指定するか

コマンド指定

XMonadのrunOrRaise関数も実行するコマンド名は条件式とは別々に指定していたので、指定すること自体はそこまで嫌では無いです。

リンクの解決は無理

この方法はボツです。

しかしだいたいのプログラムにPATHが通ってないWindowsで素直に指定すると、プログラム名が大量のディレクトリも含んでしまいます。そうするとソース内部の見栄えが悪いだけではなく、その環境特有のインストール位置をハードコーディングしてしまいます。例えばMSストアとネイティブインストールの場合PATHは違うでしょう。

まあ、 %ProgramData%\Microsoft\Windows\Start Menu\Programs からの相対パス起動とかならばまだ許容範囲ですかね…

パスが通った位置に全部リンクを移動させるよりは良さそう。

しかしこの場所にあるのはリンクですね。そのまま起動しようとしても起動できないようですね。 Path.readlink()がWindowsに対応してるかは知りませんが、それ以前にKeyhacのPythonのバージョンが古い… と思いましたが、古いのはMac版だけでWindows版は3.8でそこそこでした。じゃあ使えると思いましたが、 AttributeError: 'WindowsPath' object has no attribute 'readlink' と出てしまいました。やっぱり使えないんですねえ。

【Python】ショートカットファイル(.lnk)の正式なファイルパスを取得する - Qiita とか出てきたけどWSHとかめっちゃ呼び出したくないんですが… そもそも組み込みスクリプトなのでpipを使うのがすごい大変そう。

import win32com.client

して呼び出すのが一般的みたいですが、今回はpipは使えないので無理ですね。

一応自分でバイナリ解析をして取り出す手法などがありますが、 Windowsの仕様変更などで一瞬で動かなくなりそう。

os.startfileを使ってやれば開けるのではないか。ダメだった。

Windowsのコマンドで参照できるのではないかと思ったけれどこれもWSH使う必要があるようで。 PowerShellのコマンドに無いかなとも思ったのですが、これも結局WSHかWScriptですね… pip使える環境ならばそれにしましたけれど。

使えないのであれば仕方がない。

以下のソースは放棄します。

def start_menu_programs_path(segments: WindowsPath) -> WindowsPath:
    """
    スタートメニューに存在するプログラム(ショートカット)の指す位置を展開する。
    C:\\ProgramData\\Microsoft\\Windows\\Start Menu\\Programs\\Firefox
    のような返り値になる。
    """
    program_data = os.environ["ProgramData"]
    return WindowsPath(
        program_data, "Microsoft", "Windows", "Start Menu", "Programs", segments
    )

仕方がないのでほとんどハードコーディングします

最近はwingetかStoreでアプリ入手することが殆どですし、パスが異なることなんてそうそうないと割り切ることにしました。環境によって異なる場合はその時になったら環境を検知して分岐したり有り得そうなパスを試すPythonコードを書きます。

ナイーブですがシンプルに行きましょう。

def run_or_raise(
    keymap: Any,
    exe_name: Optional[str] = None,
    class_name: Optional[str] = None,
    window_text: Optional[str] = None,
    check_func: Optional[Callable[[Any], bool]] = None,
    force: bool = False,
    command: Optional[str] = None,
    param: Optional[str] = None,
) -> Callable[[], Any]:
    """
    XMonadの
    [runOrRaise](https://hackage.haskell.org/package/xmonad-contrib-0.17.1/docs/XMonad-Actions-WindowGo.html#v:runOrRaise)
    をKeyhacに移植する。
    既にプロセスやウィンドウが存在すればそれにフォーカスして、
    存在しなければ起動する。
    Keyhacの型情報を適切に参照するのが難しいため型定義に`Any`がしばしば入る。
    """

    def inner() -> Any:
        """機能を提供する関数を返す必要があるので内部で関数を生成する。"""
        isActive = keymap.ActivateWindowCommand(
            exe_name, class_name, window_text, check_func, force
        )()
        if isActive != None:
            # ウィンドウが見つかった場合一応その値を返す。
            return isActive
        else:
            # ウインドウが見つからなかった場合、起動する。
            com = command or exe_name
            if com == None:
                raise ValueError(f"command: {command}, exe_name: {exe_name}")
            keymap.ShellExecuteCommand(None, com, param, "", swmode="maximized")()
            return isActive

    return inner


def program_files(*pathsegments: str) -> WindowsPath:
    return WindowsPath("C:", "Program Files", *pathsegments)

これで、

    keymap_global["W-b"] = run_or_raise(
        keymap,
        exe_name="KeePassXC.exe",
        command=str(program_files("KeePassXC", "KeePassXC.exe")),
    )

みたいに指定します。

長い。 PATHが通ってる場所にJavaやAndroidアプリみたいな一意な人間が読めるアプリケーション名で指定したい。

もしかしたらMSストアからインストールしたUWPにはそういう機能があるのかもしれませんけど。段階を踏んで既存のC++/Qtのネイティブアプリみたいなのでも管理できるようにしてくれませんかね。こういうのはゼロからマネージドで作り直すことも出来ず、 Linuxバリアントみたいにソースコードちょっと弄って対応させることも出来ない悲哀を感じますね。

WSLgはスクリプト直接入力

WSLgはシェルスクリプトそのまま打ち込むことで解決ですかね。これやるとアイコンが適応されなくなることがあるんですが、解決方法が分からない。

    keymap_global["W-s"] = run_or_raise(
        keymap,
        check_func=check_func_mikutter,
        command="wslg.exe",
        param="--cd ~ -d Ubuntu -- ~/.local/bin/mikutter",
    )

ここで重要なのは、 paramは一見リストになりそうですが一つの文字列です。シェルを介しているのでよく考えてみると当然ですね。シェルを介さないほうが良いとは思う。しかしKeyhacのエコシステムから脱してまでPythonネイティブの構文を使うべきかは悩ましい所。

ストアアプリ

具体的にはAmazon Musicのことなんですけど、これの実態のexeファイルはこれどこにあるんだ…?

ストア版を使わないという簡単な解決策はなくはないんですが、前にストア版癖強いよなと考えて直接インストールしたら、ものすごい古いバージョンのクライアントがインストールされてそこからアップデートがかかってロクにメンテナンスされてなさそうだったのと、別にストア版で問題だと思ったウィンドウがデフォルトで最大化されてない問題が解決していなかったので、非ストア版を使う強い動機は今のところ存在しません。

Firefoxのような一刻も早く最新版のセキュリティフィックスを受けないとまずいソフトウェアで、尚且つ以前から自動更新の仕組みを取り入れていたソフトウェアの場合ストア版を使わないという選択肢はあり得るんですが、他のアプリはなるべくMSストア版が存在するならばそちらを使っていきたいです。

とりあえず全体を検索してみたらショートカットが出て、 C:\Program Files\WindowsApps\AmazonMobileLLC.AmazonMusic_9.2.1.0_x86__kc6t79cpj4tp0\Amazon Music.exe にリンクが貼られてたんですが、これは無効なリンクとなっていましたし、明らかに乱数で位置が決まってますしバージョンも含んでいるのでこういうパスを使いたくはない。

リンクを辿っていければとりあえず問題解決するのかもしれませんが、先程述べたようにpip使用不可でそれはかなり厳しい。

Windows 10のMicrosoft Storeアプリをコマンドラインやバッチから起動する:Tech TIPS - @IT を見てエイリアスがあるならそれ指定で行けるかと思ったのですが、 Amazon Musicにはエイリアスが存在しないようですね。

Windows Terminal の方はエイリアスがあるからバッチリなのですが。

    keymap_global["W-t"] = run_or_raise(
        keymap, exe_name="WindowsTerminal.exe", command="wt.exe"
    )

いやしかし、 exe_name="Amazon Music.exe" 指定でスイッチできるということはどこかでこのファイル名は使われているはずなのですよ。

また、 MSストアからインストールしたアプリは他のプログラムから起動できないなんてことは幾ら何でも無いと思います。連携が出来ないってレベルではない。

このリンクをコマンドプロンプトで叩くと実行できる。(ファイル名を指定して実行でも同じくできる)

任意のWindowsストアアプリをコマンドで起動する方法 | メモ帳兼日記帳なブログ

確かにパスが通ってないfirefox.exeとかでも実行できたので、 Pythonから直接os.startFileするのではなくコマンドプロンプトなどを介すると実行できたりする? というかさっきのos.startFileもパスの指定がおかしかっただけで、実は実行できたりした? 私の苦労は… いやシェルに一回パースさせるのは割と気苦労のある処理なのと、変なのが混ざりやすいからそれはまあ良いとしましょう。

試しにPowerShellからDesktopにあるlnkを実行してみたら、実行されましたね。

ただ、 Amazon Music.exeを指定してファイル名を指定して実行では実行されませんでした。

ウガー、 WindowsにもMSストアの仕様にも詳しくないから難しい…

一応今のexeファイルの位置は特定しました。 C:\Program Files\WindowsApps\AmazonMobileLLC.AmazonMusic_9.4.0.0_x86__kc6t79cpj4tp0 なので指定には使えないですね。

How to open Microsoft Store apps from Command Prompt? — Auslogics Blog を信じて、 exeファイルのある場所の、 AppxManifest.xmlの内容を見て、 PackageFamilyNameは不変であると思ってみる。変わったらまたリンク辿るなりして頑張ります。

  • PackageFamilyName: AmazonMobileLLC.AmazonMusic_kc6t79cpj4tp0
  • Id: AmazonMobileLLC.AmazonMusic

のため、コマンドラインで開く方法は、 shell:appsFolder\AmazonMobileLLC.AmazonMusic_kc6t79cpj4tp0!AmazonMobileLLC.AmazonMusic です。

開いた!

しかし、 swmode="maximized"にしているのに、起動時にウィンドウが最大化しない問題は解決してないですね…

Windows難しすぎる。なぜプログラムからプログラムを呼び出す、それだけのことにここまで苦労するとは。