aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md2
l---------hosts/Glow.qml1
-rw-r--r--hosts/Hosts.qml361
-rw-r--r--hosts/README.md46
l---------hosts/Theme.qml1
-rwxr-xr-xhosts/hosts.py135
-rw-r--r--hosts/shell.qml26
-rwxr-xr-xhosts/ssh-tab24
8 files changed, 596 insertions, 0 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 27af75d..6d0dc98 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,6 +23,8 @@ modules.
window-switcher/ expose: open windows scattered as live previews, on ALT+TAB
or the top-right hot corner of DP-1
keybinds/ live keybind reminder, on SUPER+k
+ hosts/ the vault's LAN map with ssh aliases, on ALT+SHIFT+Return;
+ replaces the rofi SSH menu
notifications/ balloons for the notification daemon's published files
They are started from `~/.config/hypr/sections/autostart.lua` and keep running
diff --git a/hosts/Glow.qml b/hosts/Glow.qml
new file mode 120000
index 0000000..779c66a
--- /dev/null
+++ b/hosts/Glow.qml
@@ -0,0 +1 @@
+../shared/Glow.qml \ No newline at end of file
diff --git a/hosts/Hosts.qml b/hosts/Hosts.qml
new file mode 100644
index 0000000..fb2c0e1
--- /dev/null
+++ b/hosts/Hosts.qml
@@ -0,0 +1,361 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import Quickshell.Wayland
+import QtQuick
+
+Scope {
+ id: root
+
+ property bool open: false
+ property string monitor: "DP-1"
+ property var hosts: []
+ property string query: ""
+ property int current: 0
+ // Non-empty while loading or after a failure; the list shows only when
+ // this is empty, so a stale list from the last opening never shows.
+ property string status: "Loading..."
+
+ // Every word must appear somewhere in the host's fields.
+ readonly property var shown: {
+ const words = root.query.toLowerCase().split(/\s+/).filter(w => w);
+ return root.hosts.filter(h => {
+ const hay = [h.name, h.ip, h.port, h.hostname, h.node, h.ct_id]
+ .concat(h.aliases).join(" ").toLowerCase();
+ return words.every(w => hay.includes(w));
+ });
+ }
+ onShownChanged: root.current = 0
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ function toggle() { root.open = !root.open; }
+ function close() { root.open = false; }
+
+ function address(h) { return h.port ? `${h.ip}:${h.port}` : h.ip; }
+
+ // What a host offers, from what its note and the ssh config carry. The
+ // first is what Enter does.
+ function actions(h) {
+ const out = h.aliases.map(a => ({ label: `ssh ${a}`,
+ cmd: [`${Quickshell.shellDir}/ssh-tab`, a] }));
+ if (h.url)
+ out.push({ label: "open", cmd: ["xdg-open", h.url] });
+ if (h.ip)
+ out.push({ label: `copy ${root.address(h)}`,
+ cmd: ["wl-copy", "--", root.address(h)] });
+ return out;
+ }
+
+ function run(action) {
+ Quickshell.execDetached(action.cmd);
+ root.close();
+ }
+
+ function move(step) {
+ root.current = Math.max(0, Math.min(root.shown.length - 1, root.current + step));
+ }
+
+ function runFirst(h) {
+ const a = h ? root.actions(h) : [];
+ if (a.length) root.run(a[0]);
+ }
+
+ // Read afresh on every opening, so a note edited in the vault or an alias
+ // added to the ssh config shows the next time, with no cache to go stale.
+ onOpenChanged: if (open) {
+ root.status = "Loading...";
+ root.query = "";
+ // A Process already running ignores running = true.
+ proc.running = false;
+ proc.running = true;
+ }
+
+ Process {
+ id: proc
+ command: ["/usr/bin/python3", `${Quickshell.shellDir}/hosts.py`]
+ stdout: StdioCollector {
+ onStreamFinished: {
+ try {
+ root.hosts = JSON.parse(text);
+ root.status = "";
+ } catch (e) {
+ root.status = "hosts.py gave nothing readable";
+ }
+ }
+ }
+ }
+
+ // A config with no visible window exits, reporting nothing. This 1x1
+ // click-through window holds the shell open while the overlay is hidden.
+ PanelWindow {
+ anchors { top: true; left: true }
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ }
+
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ screen: root.screenObj
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "keystone-hosts"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ Rectangle {
+ anchors.fill: parent
+ color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82)
+
+ MouseArea {
+ anchors.fill: parent
+ onClicked: root.close()
+ }
+
+ Column {
+ id: panel
+ anchors.fill: parent
+ anchors.margins: 40
+ spacing: 20
+
+ Rectangle {
+ width: 720
+ anchors.horizontalCenter: parent.horizontalCenter
+ height: 52
+ radius: 14
+ color: Theme.surface
+
+ // Keys reach a focused item, not a window, so the
+ // field takes all of them.
+ TextInput {
+ id: field
+ anchors.fill: parent
+ anchors.leftMargin: 18
+ anchors.rightMargin: 18
+ verticalAlignment: TextInput.AlignVCenter
+ focus: true
+ activeFocusOnTab: true
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 20
+ text: root.query
+ onTextEdited: root.query = text
+
+ Keys.onEscapePressed: root.close()
+ // The arrows move through the grid, not the
+ // text: a query is short enough to retype.
+ Keys.onLeftPressed: root.move(-1)
+ Keys.onRightPressed: root.move(1)
+ Keys.onUpPressed: root.move(-grid.columns)
+ Keys.onDownPressed: root.move(grid.columns)
+ Keys.onReturnPressed: root.runFirst(root.shown[root.current])
+ Keys.onEnterPressed: root.runFirst(root.shown[root.current])
+
+ Text {
+ anchors.verticalCenter: parent.verticalCenter
+ visible: field.text === ""
+ text: "host, ip, alias, node..."
+ color: Theme.overlay
+ font: field.font
+ }
+ }
+ }
+
+ Text {
+ visible: root.status !== ""
+ text: root.status
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 18
+ }
+
+ // Five columns in rows, each row as tall as its
+ // busiest card, so one with many chips grows its own row
+ // and no other. The margins keep the selected card's
+ // glow inside the clip. Rows rather than a GridView,
+ // whose cells all share one height.
+ Flickable {
+ id: grid
+ readonly property int columns: 5
+ visible: root.status === ""
+ width: parent.width
+ height: parent.height - y
+ clip: true
+ contentHeight: rows.height + 48
+ boundsBehavior: Flickable.StopAtBounds
+
+ // Keep the selected card in view as the arrows move.
+ function reveal(i) {
+ const r = rowRepeater.itemAt(Math.floor(i / columns));
+ if (!r) return;
+ const top = rows.y + r.y - 24;
+ const bottom = rows.y + r.y + r.height + 24;
+ if (top < contentY)
+ contentY = top;
+ else if (bottom > contentY + height)
+ contentY = bottom - height;
+ }
+
+ Connections {
+ target: root
+ function onCurrentChanged() { grid.reveal(root.current); }
+ function onShownChanged() { grid.contentY = 0; }
+ }
+
+ Column {
+ id: rows
+ x: 24
+ y: 24
+ width: grid.width - 48
+ spacing: 28
+
+ Repeater {
+ id: rowRepeater
+ model: {
+ const out = [];
+ for (let i = 0; i < root.shown.length; i += grid.columns)
+ out.push(root.shown.slice(i, i + grid.columns));
+ return out;
+ }
+
+ Row {
+ id: row
+ required property var modelData
+ required property int index
+ readonly property real tallest: {
+ let m = 0;
+ for (const c of children)
+ if (c.need) m = Math.max(m, c.need);
+ return m;
+ }
+ spacing: rows.spacing
+
+ Repeater {
+ model: row.modelData
+
+ Rectangle {
+ id: card
+ required property var modelData
+ required property int index
+ readonly property int at: row.index * grid.columns + index
+ readonly property bool selected: at === root.current
+ // What this card needs; the row takes the most.
+ readonly property real need: info.implicitHeight + 24
+
+ width: (rows.width - rows.spacing * (grid.columns - 1)) / grid.columns
+ height: row.tallest
+ radius: 14
+ color: selected ? Theme.surfaceAlt : Theme.surface
+
+ Glow {
+ anchors.fill: parent
+ radius: parent.radius
+ z: -1
+ on: card.selected
+ }
+
+ MouseArea {
+ anchors.fill: parent
+ hoverEnabled: true
+ onEntered: root.current = card.at
+ onClicked: root.runFirst(card.modelData)
+ }
+
+ Column {
+ id: info
+ x: 16
+ y: 12
+ width: parent.width - 32
+ spacing: 4
+
+ Text {
+ text: card.modelData.name
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 16
+ font.bold: true
+ }
+ Text {
+ text: [root.address(card.modelData),
+ card.modelData.hostname !== card.modelData.ip
+ ? card.modelData.hostname : ""]
+ .filter(s => s).join(" · ")
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ }
+ Text {
+ visible: card.modelData.node !== ""
+ text: card.modelData.node
+ + (card.modelData.ct_id ? ` · CT ${card.modelData.ct_id}` : "")
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ }
+
+ Flow {
+ width: parent.width
+ spacing: 8
+ topPadding: 4
+
+ Repeater {
+ model: root.actions(card.modelData)
+
+ Rectangle {
+ id: chip
+ required property var modelData
+ required property int index
+ width: label.width + 20
+ height: 24
+ radius: 12
+ color: chipMouse.containsMouse ? Theme.accent : Theme.base
+ border.width: card.selected && index === 0 ? 1 : 0
+ border.color: Theme.accent
+
+ Text {
+ id: label
+ anchors.centerIn: parent
+ text: chip.modelData.label
+ color: chipMouse.containsMouse ? Theme.base : Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: 13
+ }
+ MouseArea {
+ id: chipMouse
+ anchors.fill: parent
+ hoverEnabled: true
+ onClicked: root.run(chip.modelData)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/hosts/README.md b/hosts/README.md
new file mode 100644
index 0000000..1be1dd7
--- /dev/null
+++ b/hosts/README.md
@@ -0,0 +1,46 @@
+# hosts
+
+The LAN map from the Obsidian vault as a searchable list, over a dimmed
+screen. ALT+SHIFT+Return opens it, Escape or a click outside closes it. It
+replaces the rofi SSH menu, `blackpearl-sshmenu.sh`.
+
+Each card shows a host's `ip:port`, its hostname, the Proxmox node and CT id
+it runs on, and the actions its data allows:
+
+- `ssh <alias>`, one per `~/.ssh/config` alias pointing at it, in a kitty tab
+ (`ssh-tab`, lifted from the rofi script);
+- `open`, when the note has a `url`;
+- `copy <ip:port>`, through `wl-copy`.
+
+Type to filter (every word must match name, ip, port, hostname, node, CT id
+or alias), Up/Down to move, Enter runs the first action, chips run their own.
+
+`hosts.py` builds the list on every opening, so there is no cache. It reads
+the frontmatter of each note in the vault's `Local Network/services/`
+(`HOSTS_DIR` overrides the path) and the ssh config. An alias joins the note
+whose `ip` or `hostname` is its `HostName`, or whose short hostname is the
+alias; an alias that matches no note gets a card of its own, so everything
+the rofi menu listed is still here. Aliases with the same `HostName` share a
+card. Like the rofi script, it reads plain `Keyword value` lines only:
+`Include` and `Match` are not followed, wildcard `Host` patterns are skipped.
+
+## Running
+
+ qs -p ./hosts
+ qs -p ./hosts ipc call hosts toggle
+
+## Blur
+
+Optional, in the Hyprland config:
+
+ hl.layer_rule({
+ name = "blur-hosts",
+ match = { namespace = "^(keystone-hosts)$" },
+ blur = true,
+ xray = false,
+ ignore_alpha = 0.1,
+ })
+
+## Check
+
+ /usr/bin/python3 hosts/hosts.py --check
diff --git a/hosts/Theme.qml b/hosts/Theme.qml
new file mode 120000
index 0000000..3d2e40f
--- /dev/null
+++ b/hosts/Theme.qml
@@ -0,0 +1 @@
+../shared/Theme.qml \ No newline at end of file
diff --git a/hosts/hosts.py b/hosts/hosts.py
new file mode 100755
index 0000000..424a674
--- /dev/null
+++ b/hosts/hosts.py
@@ -0,0 +1,135 @@
+#!/usr/bin/python3
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""The LAN map as JSON: one entry per host note in the vault, each carrying
+the ~/.ssh/config aliases that point at it, plus one entry per alias that
+points at no note, so every alias the rofi menu listed is still here.
+
+ hosts.py print the list
+ hosts.py --check run the self-check
+"""
+
+import json
+import os
+import sys
+from pathlib import Path
+
+VAULT = os.environ.get(
+ "HOSTS_DIR", "~/Documents/Obsidian/my KB/Knowledge base/Local Network/services")
+SSH_CONFIG = "~/.ssh/config"
+
+
+def frontmatter(text):
+ """The `key: value` lines between the first two `---` lines."""
+ # ponytail: flat scalars only, no yaml lib; a list value stays its raw
+ # string, which is fine for the fields read here.
+ lines = text.splitlines()
+ if not lines or lines[0].strip() != "---":
+ return {}
+ out = {}
+ for line in lines[1:]:
+ if line.strip() == "---":
+ break
+ key, sep, value = line.partition(":")
+ if sep and key.strip() and not key.startswith(" "):
+ out[key.strip()] = value.strip().strip("\"'")
+ return out
+
+
+def ssh_hosts(text):
+ """Aliases in an ssh config: {alias, hostname, user, port}."""
+ # ponytail: plain "Keyword value" lines only, like blackpearl-sshmenu.sh;
+ # Include and Match are not followed, wildcard Host patterns are skipped.
+ hosts, cur = [], None
+ for line in text.splitlines():
+ parts = line.split(None, 1)
+ if len(parts) < 2 or parts[0].startswith("#"):
+ continue
+ key, value = parts[0].lower(), parts[1].strip()
+ if key == "host":
+ name = value.split()[0]
+ cur = None if any(c in name for c in "*?") else {
+ "alias": name, "hostname": name, "user": "", "port": ""}
+ if cur:
+ hosts.append(cur)
+ elif cur and key in ("hostname", "user", "port"):
+ cur[key] = value
+ return hosts
+
+
+def merge(notes, aliases):
+ """notes: [(title, frontmatter)]. Each alias joins the first note whose ip
+ or hostname is its HostName, or whose short hostname is the alias."""
+ out = []
+ for title, fm in notes:
+ out.append({
+ "name": title,
+ "ip": fm.get("ip", ""),
+ "port": fm.get("port", ""),
+ "url": fm.get("url", ""),
+ "hostname": fm.get("hostname", ""),
+ "node": fm.get("node", ""),
+ "ct_id": fm.get("ct_id", ""),
+ "aliases": [],
+ "user": "",
+ })
+ for a in aliases:
+ target = a["hostname"]
+ host = next((h for h in out if target and target in (h["ip"], h["hostname"])
+ or a["alias"] == h["hostname"].split(".")[0]), None)
+ if host:
+ host["aliases"].append(a["alias"])
+ host["user"] = host["user"] or a["user"]
+ else:
+ out.append({
+ "name": a["alias"], "ip": target, "port": a["port"], "url": "",
+ "hostname": target, "node": "", "ct_id": "",
+ "aliases": [a["alias"]], "user": a["user"],
+ })
+ return out
+
+
+def main():
+ notes = [(p.stem, frontmatter(p.read_text(errors="replace")))
+ for p in sorted(Path(VAULT).expanduser().glob("*.md"))]
+ cfg = Path(SSH_CONFIG).expanduser()
+ aliases = ssh_hosts(cfg.read_text(errors="replace")) if cfg.exists() else []
+ json.dump(merge(notes, aliases), sys.stdout)
+
+
+def check():
+ fm = frontmatter("---\ntags: [a, b]\nip: 10.0.0.7\nport: 5000\n"
+ "node: HP server\n---\nbody: not this\n")
+ assert fm == {"tags": "[a, b]", "ip": "10.0.0.7", "port": "5000",
+ "node": "HP server"}, fm
+ assert frontmatter("no frontmatter\n") == {}
+
+ cfg = ssh_hosts("Host *\n User x\nHost box\n HostName 10.0.0.7\n"
+ " User root\nHost web\n HostName web.example.org\n"
+ "Host lone\n Port 2222\n")
+ assert [h["alias"] for h in cfg] == ["box", "web", "lone"], cfg
+ assert cfg[0]["user"] == "root" and cfg[2]["port"] == "2222"
+
+ hosts = merge([("Box", fm), ("Web", {"hostname": "web.example.org"}),
+ ("Media", {"ip": "10.0.0.9", "hostname": "media.example.org"})],
+ cfg + ssh_hosts("Host media\n HostName 10.0.0.99\n"))
+ by = {h["name"]: h for h in hosts}
+ assert by["Box"]["aliases"] == ["box"] and by["Box"]["user"] == "root"
+ assert by["Web"]["aliases"] == ["web"]
+ assert by["Media"]["aliases"] == ["media"], "short hostname match"
+ assert by["lone"]["port"] == "2222" and by["lone"]["node"] == ""
+ assert len(hosts) == 4
+ print("ok")
+
+
+if __name__ == "__main__":
+ check() if "--check" in sys.argv else main()
diff --git a/hosts/shell.qml b/hosts/shell.qml
new file mode 100644
index 0000000..be1eb9c
--- /dev/null
+++ b/hosts/shell.qml
@@ -0,0 +1,26 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+
+ShellRoot {
+ Hosts { id: hosts }
+
+ // Bound to ALT + SHIFT + Return in Hyprland:
+ // `qs -p <this dir> ipc call hosts toggle`
+ IpcHandler {
+ target: "hosts"
+ function toggle() { hosts.toggle(); }
+ function open() { hosts.open = true; }
+ function close() { hosts.close(); }
+ }
+}
diff --git a/hosts/ssh-tab b/hosts/ssh-tab
new file mode 100755
index 0000000..8e8a310
--- /dev/null
+++ b/hosts/ssh-tab
@@ -0,0 +1,24 @@
+#!/bin/bash
+# ssh-tab - ssh to an alias in a tab of the running kitty
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+# Lifted from blackpearl-sshmenu.sh, the rofi menu this shell replaces.
+# A tab in the running kitty, through the socket kitty.conf's listen_on opens;
+# a new terminal when no kitty is up.
+# ponytail: first socket wins when several kitty instances run.
+name=${1:?usage: ssh-tab <alias>}
+sockets=("$XDG_RUNTIME_DIR"/kitty-*)
+if [[ -S ${sockets[0]} ]] && kitten @ --to "unix:${sockets[0]}" launch \
+ --type=tab --tab-title "$name" ssh "$name" >/dev/null; then
+ exec hyprctl dispatch 'hl.dsp.focus({ window = "class:kitty" })' >/dev/null
+fi
+exec kitty ssh "$name"