diff options
| -rw-r--r-- | AGENTS.md | 2 | ||||
| l--------- | hosts/Glow.qml | 1 | ||||
| -rw-r--r-- | hosts/Hosts.qml | 361 | ||||
| -rw-r--r-- | hosts/README.md | 46 | ||||
| l--------- | hosts/Theme.qml | 1 | ||||
| -rwxr-xr-x | hosts/hosts.py | 135 | ||||
| -rw-r--r-- | hosts/shell.qml | 26 | ||||
| -rwxr-xr-x | hosts/ssh-tab | 24 |
8 files changed, 596 insertions, 0 deletions
@@ -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" |
