やりたかったこと
Ubuntu(GNOME 50, Wayland)で、2枚のモニターを次のように使いたいと考えました。
- 横向きのメインモニター:普段の作業。作業ログを流す端末を、決まったワークスペースに置く
- 縦向きのサブモニター:ブラウザのキオスク表示を常に出しておく
X11 なら wmctrl や xdotool で済む話です。しかし Wayland では、外部のプログラムが他のアプリのウィンドウを動かすことはできません。そこで、小さな GNOME Shell 拡張に「置くだけ」をさせ、どこに置くかは JSON ファイルで外から渡す形にしました。
動作確認:Ubuntu, GNOME Shell 50.1, Wayland, ptyxis 端末, Firefox(snap)
構成
配置ルールを書くスクリプト(普通の Python。systemd のユーザーサービス)
│ workspace_rules.json を書き換える
▼
GNOME Shell 拡張(ファイルを監視し、ウィンドウを動かすだけ)
拡張側に判断を持たせないのがポイントです。拡張のコードを変えると、Wayland では再ログインしないと反映されません。ルールをファイルに出しておけば、拡張を書き換えずに配置を変えられます。
ルールファイルの例です。
{
"pids": {"352394": 2, "352396": 3},
"dashboard_pids": [350290],
"dashboard_connector": "HDMI-1",
"keep_ws1": true
}
-
pids:ウィンドウを持つプロセスID → 置きたいワークスペース番号(1始まり) -
dashboard_pids/dashboard_connector:このプロセスのウィンドウは、指定したコネクタのモニターへ移す -
keep_ws1:新しく開いたウィンドウがワークスペース1に来たら、最後のワークスペースへ移す
ウィンドウをどう見分けるか
いちばん困ったのがここです。
- タイトルで見分けると、端末の中のアプリがタイトルを書き換えるたびに外れます
- 普通に開いた端末ウィンドウは、すべて同じ1つのプロセスに属していて、プロセスIDで区別できません
GNOME の端末アプリ ptyxis には、ウィンドウごとに別プロセスで起動する -s オプションがあります。これを使うと、起動したプロセスのIDが、そのままウィンドウのプロセスIDになります。
# 読み取り専用で tmux セッションを映す端末を、独立プロセスで開く
ptyxis -s --new-window -x "tmux attach -r -t work-log"
Python からは subprocess.Popen(...).pid をそのままルールに書けます。
拡張のコード
metadata.json の shell-version は、自分の環境のバージョンに合わせてください。
{
"uuid": "my-workspaces@example.local",
"name": "My Workspaces",
"description": "Place windows by process id using an external JSON rules file",
"shell-version": ["50"]
}
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import Meta from 'gi://Meta';
import {Extension} from 'resource:///org/gnome/shell/extensions/extension.js';
const RULES = GLib.build_filenamev([GLib.get_home_dir(), '.local/share/myws/workspace_rules.json']);
export default class MyWorkspaces extends Extension {
enable() {
this._rules = {pids: {}, dashboard_pids: [], keep_ws1: true};
this._timeouts = new Set();
this._load();
this._monitor = Gio.File.new_for_path(RULES).monitor_file(Gio.FileMonitorFlags.NONE, null);
this._monId = this._monitor.connect('changed', () => this._defer(() => { this._load(); this._applyAll(); }, 200));
this._created = global.display.connect('window-created', (_d, w) => this._defer(() => this._apply(w, true), 400));
this._defer(() => this._applyAll(), 500);
}
disable() {
for (const id of this._timeouts) GLib.source_remove(id);
this._timeouts.clear();
if (this._monitor) { this._monitor.disconnect(this._monId); this._monitor.cancel(); this._monitor = null; }
if (this._created) { global.display.disconnect(this._created); this._created = 0; }
this._rules = null;
}
_defer(fn, ms) {
const id = GLib.timeout_add(GLib.PRIORITY_DEFAULT, ms, () => {
this._timeouts.delete(id);
try { fn(); } catch (e) { logError(e, 'my-workspaces'); }
return GLib.SOURCE_REMOVE;
});
this._timeouts.add(id);
}
_load() {
try {
const [, bytes] = GLib.file_get_contents(RULES);
const r = JSON.parse(new TextDecoder().decode(bytes));
if (r && typeof r === 'object') this._rules = r;
} catch (e) { /* 読めなければ前回のルールのまま */ }
}
_applyAll() {
for (const a of global.get_window_actors()) this._apply(a.meta_window, false);
}
_monitorFor(connector) {
try {
const i = global.backend.get_monitor_manager().get_monitor_for_connector(connector);
return Number.isInteger(i) && i >= 0 ? i : -1;
} catch (e) { return -1; }
}
_apply(w, isNew) {
if (!w || !this._rules || w.get_window_type() !== Meta.WindowType.NORMAL) return;
const pid = w.get_pid();
if ((this._rules.dashboard_pids || []).includes(pid) && this._rules.dashboard_connector) {
const m = this._monitorFor(this._rules.dashboard_connector);
if (m >= 0 && w.get_monitor() !== m) {
const fs = w.is_fullscreen();
if (fs) w.unmake_fullscreen();
w.move_to_monitor(m);
if (fs) w.make_fullscreen();
}
return;
}
if (w.is_on_all_workspaces()) return;
const wm = global.workspace_manager, cur = w.get_workspace();
let t = null;
const ws = (this._rules.pids || {})[String(pid)];
if (Number.isInteger(ws) && ws >= 1) t = ws - 1;
if (t === null) {
// 既に開いているウィンドウは動かさない。新しく開いたものだけ
if (!isNew || !this._rules.keep_ws1 || !cur || cur.index() !== 0 || wm.n_workspaces < 2) return;
t = wm.n_workspaces - 1;
}
if (t >= wm.n_workspaces || (cur && cur.index() === t)) return;
w.change_workspace_by_index(t, false);
}
}
ハマったところ
1. 新しい拡張は、再ログインしないと読み込まれない
Wayland では GNOME Shell を再起動できないため、新しく置いた拡張は次のログインまで認識されません。gnome-extensions enable も「存在しません」と返ります。次のログインで有効になるよう、先に設定だけ入れておけます。
gsettings get org.gnome.shell enabled-extensions
# 出てきたリストに uuid を足して set し直す
2. ワークスペースの数を固定する
動的ワークスペースのままだと、番号がずれます。
gsettings set org.gnome.mutter dynamic-workspaces false
gsettings set org.gnome.desktop.wm.preferences num-workspaces 3
3. サブモニターのウィンドウは、全ワークスペースで見える
org.gnome.mutter workspaces-only-on-primary が true(既定)だと、ワークスペースの切り替えはメインモニターだけに効きます。サブモニターへ移したキオスク表示は、どのワークスペースにいても見えたままです。今回はこの挙動をそのまま使いました。
4. 拡張を有効にした瞬間に、手元のウィンドウが飛ばないようにする
最初の版は、有効にしたときに開いていたウィンドウまでワークスペース1から追い出していました。作業中のウィンドウが急に消えるので、window-created で来た新しいウィンドウだけを対象にしています(コードの isNew)。
5. snap の Firefox はホームの隠しフォルダにプロファイルを置けない
キオスク用に --profile ~/.local/share/... を指定したところ、「Firefox is already running」と出て起動しませんでした。snap の Firefox はそこに書き込めず、ロックを取れないためです。~/snap/firefox/common/ の下に置くと動きました。
P="$HOME/snap/firefox/common/kiosk-profile"; mkdir -p "$P"
firefox --no-remote --profile "$P" --kiosk "http://127.0.0.1:8790/"
まとめ
- Wayland では外部からウィンドウを動かせない。動かすのは GNOME Shell 拡張の中だけにする
- 拡張は「ルールファイルを読んで置くだけ」にし、判断は外のスクリプトに持たせる。こうすると再ログインせずに配置を変えられる
- ウィンドウの見分けにはプロセスIDを使う。ptyxis の
-sで、端末をウィンドウごとに別プロセスにできる