diff --git a/modules/user/hyprland.nix b/modules/user/hyprland.nix index 81012d7..3e77874 100644 --- a/modules/user/hyprland.nix +++ b/modules/user/hyprland.nix @@ -170,7 +170,7 @@ in { lucidHyprlandBinds = [ (bind "SUPER + V" (exec "ghostty --title=clipse -e clipse")) - (bind "SUPER + X" (exec "qs ipc call -- vaultwarden open")) + (bind "SUPER + X" (exec "qs ipc call -- launcher search '>vault'")) (hyprLua.bind "SUPER + SHIFT + V" (hyprLua.execLauncher "qs ipc call launcher clipboard")) (bind "Print" (exec "qs ipc call screenshot full")) (bind "SUPER + Print" (exec "qs ipc call screenshot full")) diff --git a/modules/user/lucid/Vaultwarden.qml b/modules/user/lucid/Vaultwarden.qml index 5ab6bb7..952f8bf 100644 --- a/modules/user/lucid/Vaultwarden.qml +++ b/modules/user/lucid/Vaultwarden.qml @@ -2,108 +2,50 @@ import QtQuick import Quickshell import Quickshell.Hyprland import Quickshell.Io -import Quickshell.Wayland -import qs +pragma Singleton -Item { +Singleton { id: root - property bool shown: false - property bool busy: false - property string status: "" + property var entries: [] + property bool loading: false + property string error: "" property string targetAddress: "" property string pendingPassword: "" property var passwordProcess: null - property var entries: [] - property int selectedIndex: 0 - readonly property var filteredEntries: { - const query = search.text.trim().toLocaleLowerCase(); - if (query === "") - return root.entries; + signal passwordReady(string password, string targetAddress) - return root.entries.filter((entry) => - entry.name.toLocaleLowerCase().includes(query) - || entry.user.toLocaleLowerCase().includes(query) - || entry.folder.toLocaleLowerCase().includes(query)); - } - - function openPicker() { - if (typeDelay.running) + function refresh() { + if (listReader.running) return; - if (root.shown) { - root.closePicker(); - return; - } - const active = Hyprland.activeToplevel; - root.targetAddress = active ? active.address : ""; - root.shown = true; - root.busy = true; - root.status = "Loading vault…"; - root.entries = []; - search.text = ""; - root.selectedIndex = 0; + root.loading = true; + root.error = ""; listReader.command = ["rbw", "list", "--fields", "id,name,user,folder"]; listReader.running = true; - Qt.callLater(() => search.forceActiveFocus()); } - function closePicker() { - typeDelay.stop(); - if (listReader.running) - listReader.running = false; - if (root.passwordProcess) { - root.passwordProcess.running = false; - root.passwordProcess.destroy(); - root.passwordProcess = null; - } - root.shown = false; - root.busy = false; - root.status = ""; - root.pendingPassword = ""; - } - - function choose(entry) { - if (!entry || root.busy) + function getPassword(id, target) { + if (root.passwordProcess) return; - root.busy = true; - root.status = "Reading password…"; - root.passwordProcess = passwordProcessComponent.createObject(root, { entryId: entry.id }); + root.targetAddress = target || ""; + root.error = ""; + root.passwordProcess = passwordProcessComponent.createObject(root, { entryId: id }); if (!root.passwordProcess) { - root.busy = false; - root.status = "Could not start rbw."; + root.error = "Could not start rbw."; return; } root.passwordProcess.running = true; } - function deliverPassword(password) { - if (password === "") { - root.busy = false; - root.status = "This entry has an empty password."; - return; - } - + function typePassword(password, target) { root.pendingPassword = password; - root.shown = false; - root.status = ""; + root.targetAddress = target || ""; typeDelay.restart(); } - IpcHandler { - target: "vaultwarden" - - function open(): void { - root.openPicker(); - } - - function close(): void { - root.closePicker(); - } - } - Process { id: listReader @@ -113,9 +55,10 @@ Item { } onExited: (code) => { - root.busy = false; + root.loading = false; if (code !== 0) { - root.status = "Could not read the vault. Unlock it with rbw, then try again."; + root.error = "Could not read the vault. Unlock it with rbw, then retry."; + root.entries = []; return; } @@ -128,7 +71,7 @@ Item { folder: fields[3] || "" }; }).filter((entry) => entry.id !== ""); - root.status = root.entries.length === 0 ? "No entries in the vault." : ""; + root.error = ""; } } @@ -148,10 +91,9 @@ Item { if (root.passwordProcess === passwordJob) root.passwordProcess = null; if (code !== 0) { - root.busy = false; - root.status = "Could not read that password."; + root.error = "Could not read that password."; } else { - root.deliverPassword(passwordOutput.text.replace(/\r?\n$/, "")); + root.passwordReady(passwordOutput.text.replace(/\r?\n$/, ""), root.targetAddress); } passwordJob.destroy(); } @@ -167,197 +109,21 @@ Item { write(root.pendingPassword); stdinEnabled = false; root.pendingPassword = ""; - root.busy = false; + root.targetAddress = ""; } } Timer { id: typeDelay - interval: 180 + interval: 260 onTriggered: { if (root.targetAddress !== "") Hyprland.dispatch("focuswindow address:" + root.targetAddress); - root.targetAddress = ""; typer.running = false; typer.stdinEnabled = true; typer.running = true; } } - - PanelWindow { - id: window - - visible: root.shown - anchors { - top: true - bottom: true - left: true - right: true - } - color: "transparent" - exclusionMode: ExclusionMode.Ignore - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: root.shown ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None - - Rectangle { - anchors.fill: parent - color: "#99000000" - - MouseArea { - anchors.fill: parent - onClicked: root.closePicker() - } - } - - Rectangle { - id: card - - anchors.centerIn: parent - width: Math.min(parent.width - Theme.dp(40), Theme.dp(620)) - height: Math.min(parent.height - Theme.dp(40), Theme.dp(560)) - radius: Theme.shapeXl - color: Theme.surfaceContainer - border.width: 1 - border.color: Theme.outline - - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.LeftButton - onClicked: (mouse) => mouse.accepted = true - } - - Column { - anchors.fill: parent - anchors.margins: Theme.dp(24) - spacing: Theme.dp(14) - - Text { - text: "Vaultwarden" - color: Theme.text - font.pixelSize: Theme.fontHeadlineLg - font.weight: Font.DemiBold - } - - Rectangle { - width: parent.width - height: Theme.dp(52) - radius: Theme.shapeMd - color: Theme.surfaceHigh - border.width: search.activeFocus ? 2 : 1 - border.color: search.activeFocus ? Theme.accent : Theme.outline - - TextInput { - id: search - - anchors.fill: parent - anchors.leftMargin: Theme.dp(16) - anchors.rightMargin: Theme.dp(16) - verticalAlignment: TextInput.AlignVCenter - color: Theme.text - selectionColor: Theme.accent - selectedTextColor: Theme.fgAccent - font.pixelSize: Theme.fontTitle - clip: true - focus: root.shown - selectByMouse: true - - Text { - anchors.fill: parent - verticalAlignment: Text.AlignVCenter - text: "Search entries…" - color: Theme.subtext - font: search.font - visible: search.text === "" - } - - Keys.onPressed: (event) => { - if (event.key === Qt.Key_Escape) { - root.closePicker(); - event.accepted = true; - } else if (event.key === Qt.Key_Down) { - root.selectedIndex = Math.max(0, Math.min(root.filteredEntries.length - 1, root.selectedIndex + 1)); - if (root.filteredEntries.length > 0) - list.positionViewAtIndex(root.selectedIndex, ListView.Contain); - event.accepted = true; - } else if (event.key === Qt.Key_Up) { - root.selectedIndex = Math.max(0, root.selectedIndex - 1); - if (root.filteredEntries.length > 0) - list.positionViewAtIndex(root.selectedIndex, ListView.Contain); - event.accepted = true; - } - } - - onTextChanged: root.selectedIndex = 0 - onAccepted: { - if (root.filteredEntries.length > 0) - root.choose(root.filteredEntries[root.selectedIndex] || root.filteredEntries[0]); - } - } - } - - ListView { - id: list - - width: parent.width - height: Math.max(0, parent.height - Theme.dp(122)) - clip: true - spacing: Theme.dp(4) - model: root.filteredEntries - currentIndex: root.selectedIndex - - delegate: Rectangle { - required property var modelData - required property int index - - width: list.width - height: Theme.dp(60) - radius: Theme.shapeMd - color: index === root.selectedIndex ? Theme.bgHover : "transparent" - - Column { - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: Theme.dp(14) - anchors.rightMargin: Theme.dp(14) - spacing: Theme.dp(3) - - Text { - width: parent.width - text: modelData.name - color: Theme.text - font.pixelSize: Theme.fontTitle - elide: Text.ElideRight - } - - Text { - width: parent.width - text: [modelData.user, modelData.folder].filter((part) => part !== "").join(" · ") - color: Theme.subtext - font.pixelSize: Theme.fontLabel - elide: Text.ElideRight - visible: text !== "" - } - } - - MouseArea { - anchors.fill: parent - onEntered: root.selectedIndex = index - onClicked: root.choose(modelData) - } - } - } - - Text { - width: parent.width - text: root.busy || root.status !== "" ? root.status : "↑ ↓ Select Enter Type password Esc Close" - color: root.status !== "" && !root.busy ? Theme.warning : Theme.subtext - font.pixelSize: Theme.fontLabel - elide: Text.ElideRight - } - } - } - } } diff --git a/modules/user/lucid/default.nix b/modules/user/lucid/default.nix index 69af926..5e5de24 100644 --- a/modules/user/lucid/default.nix +++ b/modules/user/lucid/default.nix @@ -16,7 +16,7 @@ let desc = "Type a password from Vaultwarden"; category = "Apps"; type = "exec"; - cmd = "qs ipc call -- vaultwarden open"; + cmd = "qs ipc call -- launcher search '>vault'"; } ]; }); mergeVaultwardenBind = pkgs.writeText "lucid-merge-vaultwarden-bind.jq" '' @@ -211,7 +211,96 @@ pathlib.Path(sys.argv[2]).write_text(source.split(marker, 1)[0]) PY ''; liveDecoder = if builtins.elem osConfig.networking.hostName [ "Rias" "Eclipse" ] then "cuda" else "vaapi"; - source = pkgs.applyPatches { + vaultwardenLauncherPatch = pkgs.writeText "lucid-vaultwarden-launcher.py" '' +import pathlib +import sys + +face_path, dock_path = map(pathlib.Path, sys.argv[1:]) +face = face_path.read_text() +dock = dock_path.read_text() + +def replace_once(source, old, new, label): + count = source.count(old) + if count != 1: + raise SystemExit(f"Lucid {label} anchor changed (found {count}); refusing an unsafe launcher patch") + return source.replace(old, new, 1) + +face = replace_once(face, + ' "clipboard": "Search clipboard history",\n', + ' "clipboard": "Search clipboard history",\n "vaultwarden": "Search Vaultwarden",\n', "launcher placeholder") +face = replace_once(face, + ' "clipboard": "content_paste",\n', + ' "clipboard": "content_paste",\n "vaultwarden": "key",\n', "launcher icon") +face = replace_once(face, + ' "icon": "content_paste"\n }, {\n "key": "wallpaper",', + ' "icon": "content_paste"\n }, {\n "key": "vaultwarden",\n "label": "Vaultwarden",\n "icon": "key"\n }, {\n "key": "wallpaper",', "launcher mode") +face = replace_once(face, + ' emptyLabel: {\n', + ' emptyLabel: {\n if (face.displayMode === "vaultwarden")\n return "No matching vault entries";\n', "launcher empty label") +face = replace_once(face, + ' emptyHint: {\n', + ' emptyHint: {\n if (face.displayMode === "vaultwarden")\n return "Unlock the vault with rbw, then search by name, username, or folder.";\n', "launcher empty hint") + +dock = replace_once(dock, + ' if (q.indexOf(">clip") === 0)\n return "clipboard";\n', + ' if (q.indexOf(">clip") === 0)\n return "clipboard";\n\n if (q.indexOf(">vault") === 0)\n return "vaultwarden";\n', "launcher prefix") +dock = replace_once(dock, + ' if (mode === "clipboard")\n return ">clip";\n', + ' if (mode === "clipboard")\n return ">clip";\n\n if (mode === "vaultwarden")\n return ">vault";\n', "launcher mode prefix") +dock = replace_once(dock, + ' if (dockWindow.mode === "clipboard")\n Clip.refresh();\n', + ' if (dockWindow.mode === "clipboard")\n Clip.refresh();\n if (dockWindow.mode === "vaultwarden")\n Vaultwarden.refresh();\n', "launcher mode activation") +dock = replace_once(dock, + ' readonly property var allCommands: [{', + """ Connections { + target: Vaultwarden + + function onEntriesChanged() { dockWindow.rebuildResults(); } + function onLoadingChanged() { dockWindow.rebuildResults(); } + function onErrorChanged() { dockWindow.rebuildResults(); } + function onPasswordReady(password, targetAddress) { + dockWindow.menuOpen = false; + Vaultwarden.typePassword(password, targetAddress); + } + } + + readonly property var allCommands: [{""", "vaultwarden updates") +dock = replace_once(dock, + ' function rebuildResults() {', + """ function vaultRows(query) { + if (Vaultwarden.loading) + return [dockWindow.headerRow("Loading Vaultwarden…")]; + if (Vaultwarden.error !== "") + return [dockWindow.headerRow(Vaultwarden.error)]; + + var rows = []; + var entries = Vaultwarden.entries || []; + for (var i = 0; i < entries.length; i++) { + var entry = entries[i]; + var haystack = (entry.name + " " + entry.user + " " + entry.folder).toLowerCase(); + if (query !== "" && haystack.indexOf(query) === -1) + continue; + var detail = [entry.user, entry.folder].filter((part) => part !== "").join(" · "); + rows.push(dockWindow.makeRow("vaultwarden", "vault-" + entry.id, entry.name, detail, { + "glyph": "key", + "payload": entry.id + })); + } + return rows; + } + + function rebuildResults() {""", "vaultwarden search") +dock = replace_once(dock, + ' } else if (mode === "clipboard") {\n var ents = Clip.entries;', + ' } else if (mode === "vaultwarden") {\n rows = dockWindow.vaultRows(q);\n } else if (mode === "clipboard") {\n var ents = Clip.entries;', "vaultwarden results") +dock = replace_once(dock, + ' } else if (row.kind === "clip") {\n Clip.copy(row.payload);\n dockWindow.menuOpen = false;\n', + ' } else if (row.kind === "clip") {\n Clip.copy(row.payload);\n dockWindow.menuOpen = false;\n } else if (row.kind === "vaultwarden") {\n Vaultwarden.getPassword(row.payload, Hyprland.activeToplevel ? Hyprland.activeToplevel.address : "");\n', "vaultwarden selection") + +face_path.write_text(face) +dock_path.write_text(dock) + ''; + baseSource = pkgs.applyPatches { src = pkgs.fetchgit { url = "https://github.com/Sn3akyy1/lucid.git"; rev = "bdd7f79da18efd837f79dcecbe5d2ec178abc10c"; @@ -287,6 +376,13 @@ PY '') ]; }; + source = pkgs.runCommand "lucid-vaultwarden-launcher-source" { + nativeBuildInputs = [ pkgs.python3 ]; + } '' + cp -r ${baseSource} "$out" + chmod -R u+w "$out" + python3 ${vaultwardenLauncherPatch} "$out/luciddocks/LauncherFace.qml" "$out/luciddocks/Dock.qml" + ''; wallpaperDir = "${config.home.homeDirectory}/Wallpapers"; wallpaperScript = "${config.home.homeDirectory}/.config/hypr/scripts/wallpaper/set-wallpaper.sh"; widgetLayout = if osConfig.networking.hostName == "Rias" diff --git a/modules/user/lucid/lucid-shell.qml b/modules/user/lucid/lucid-shell.qml index a5189ed..aed1e70 100644 --- a/modules/user/lucid/lucid-shell.qml +++ b/modules/user/lucid/lucid-shell.qml @@ -126,9 +126,6 @@ ShellRoot { WidgetLayer { } - Vaultwarden { - } - WidgetIpc { }