From 901bce0e2b691aeb30ca9a89d45af956920248d4 Mon Sep 17 00:00:00 2001 From: rederyk Date: Sun, 17 May 2026 16:12:21 +0200 Subject: [PATCH 1/3] feat(keepass): add KeePass password manager integration Adds an overlay panel for KeePassXC with secure unlock flow, entry search, password copy/reveal/generate, multi-vault support, and a vault create flow. Designed as an always-mapped Wayland surface (mask-based visibility) so the IpcHandler stays reachable even when the panel is closed. Key design points: - Backend: quickshell-keepass script handles all keepassxc-cli calls; KeePass.qml singleton manages state, processes, and an IPC handler (toggle / add) that is always active via a _keepassReady binding in ShellIiPanels.qml. - Panel: GlassBackground surface, no ExclusionMode (defaults to Normal so the compositor auto-positions below the bar's exclusive zone), WlrKeyboardFocus.Exclusive only while open. Focus is bound declaratively (focus: KeePass.open) and vault-list focus is restored after the async scan via an onAvailableVaultsChanged handler. - Config: keepass.vaultDir is the only user-settable key; vault path is runtime-only. Panel registered as iiKeepass in enabledPanels. - Graceful degrade: if keepassxc-cli is absent the panel shows an install banner instead of crashing on every action. - Translations: keepass_* strings added to all 15 locale files. --- ShellIiPanels.qml | 6 + defaults/config.json | 7 +- docs/IPC.md | 18 + docs/PACKAGES.md | 2 + modules/common/Config.qml | 5 + modules/keepass/KeepassPanel.qml | 1048 ++++++++++++++++++++++++++++ scripts/lib/ipc-registry.sh | 20 +- scripts/quickshell-keepass | 458 ++++++++++++ sdata/dist-arch/inir-deps/PKGBUILD | 1 + services/KeePass.qml | 509 ++++++++++++++ services/qmldir | 1 + translations/ar_SA.json | 18 +- translations/de_DE.json | 18 +- translations/en_US.json | 18 +- translations/es_AR.json | 18 +- translations/fr_FR.json | 18 +- translations/he_HE.json | 18 +- translations/hi_IN.json | 18 +- translations/it_IT.json | 18 +- translations/ja_JP.json | 18 +- translations/ko_KR.json | 18 +- translations/pt_BR.json | 19 +- translations/ru_RU.json | 18 +- translations/uk_UA.json | 18 +- translations/vi_VN.json | 18 +- translations/zh_CN.json | 18 +- 26 files changed, 2308 insertions(+), 38 deletions(-) create mode 100644 modules/keepass/KeepassPanel.qml create mode 100755 scripts/quickshell-keepass create mode 100644 services/KeePass.qml diff --git a/ShellIiPanels.qml b/ShellIiPanels.qml index 855b5ef4b..a61e54cdd 100644 --- a/ShellIiPanels.qml +++ b/ShellIiPanels.qml @@ -23,6 +23,7 @@ import qs.modules.wallpaperSelector import qs.modules.ii.overlay import qs.modules.shellUpdate import "modules/clipboard" as ClipboardModule +import "modules/keepass" as KeepassModule import QtQuick import Quickshell @@ -35,6 +36,10 @@ import "." Item { id: panelsRoot + // Force KeePass singleton to instantiate so its IpcHandler is always active, + // even when iiKeepass is absent from enabledPanels (singletons are lazy by default). + readonly property bool _keepassReady: KeePass.available + // Immediate panels — visible at first frame or must catch early events // Uses `active` which loads synchronously (required for first-frame visibility) component PanelLoader: LazyLoader { @@ -84,6 +89,7 @@ Item { DeferredPanelLoader { identifier: "iiClipboard"; component: ClipboardModule.ClipboardPanel {} } DeferredPanelLoader { identifier: "iiShellUpdate"; component: ShellUpdateOverlay {} } DeferredPanelLoader { identifier: "iiRecordingOsd"; component: RecordingOsd {} } + DeferredPanelLoader { identifier: "iiKeepass"; component: KeepassModule.KeepassPanel {} } LazyLoader { active: Config.ready && (Config.options?.background?.effects?.ripple?.enable ?? false) diff --git a/defaults/config.json b/defaults/config.json index 69721be91..937e4085e 100644 --- a/defaults/config.json +++ b/defaults/config.json @@ -1700,7 +1700,8 @@ "iiWallpaperSelector", "iiCoverflowSelector", "iiClipboard", - "iiShellUpdate" + "iiShellUpdate", + "iiKeepass" ], "knownPanels": [], "powerProfiles": { @@ -1709,5 +1710,9 @@ }, "voiceSearch": { "duration": 5 + }, + "keepass": { + "vaultDir": "", + "cacheTtl": 300 } } diff --git a/docs/IPC.md b/docs/IPC.md index 3fc0b8d62..5b7fba663 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -520,6 +520,24 @@ bind "Mod+Shift+W" { spawn "inir" "panelFamily" "cycle"; } --- +### keepass + +KeePass password manager overlay. Search entries, copy passwords/usernames, and add new entries. + +| Function | Description | +|----------|-------------| +| `toggle` | Open/close KeePass panel | +| `add` | Open panel in "add entry" mode, pre-filling title from primary selection | + +```kdl +bind "Mod+P" { spawn "inir" "keepass" "toggle"; } +bind "Mod+Ctrl+P" { spawn "inir" "keepass" "add"; } +``` + +Requires `keepassxc` (for `keepassxc-cli`), `libsecret` (for `secret-tool`) and a `.kdbx` vault. Set `keepass.vaultDir` in the shell config to point at the directory containing your vaults (defaults to `~/.local/share/keepassqs`); existing vaults are listed in the picker, and new ones can be created from the UI. Password caching is handled securely via the system keyring. + +--- + ### shellUpdate Shell update checker. Monitors the git repo for new commits and shows an update overlay. diff --git a/docs/PACKAGES.md b/docs/PACKAGES.md index 14bc46154..e726548eb 100644 --- a/docs/PACKAGES.md +++ b/docs/PACKAGES.md @@ -197,6 +197,8 @@ Not installed by default, but useful. The shell handles their absence gracefully | Package | Purpose | Used by | |---------|---------|---------| +| `keepassxc` | KeePass password manager (`keepassxc-cli`) | KeePass overlay panel | +| `libsecret` | Secret Service CLI (`secret-tool`) | KeePass password caching | | `warp-cli` | Cloudflare WARP VPN toggle | Quick toggles panel | | `ollama` | Local LLM for AI chat | Sidebar AI assistant | | `cava` | Audio visualizer | Bar widget (optional) | diff --git a/modules/common/Config.qml b/modules/common/Config.qml index bd9beb97f..6449931cf 100644 --- a/modules/common/Config.qml +++ b/modules/common/Config.qml @@ -1655,6 +1655,11 @@ Singleton { property int duration: 5 } + property JsonObject keepass: JsonObject { + property string vaultDir: "" + property int cacheTtl: 300 // seconds the vault stays unlocked + } + property JsonObject search: JsonObject { property int nonAppResultDelay: 30 // This prevents lagging when typing property string engineBaseUrl: "https://www.google.com/search?q=" diff --git a/modules/keepass/KeepassPanel.qml b/modules/keepass/KeepassPanel.qml new file mode 100644 index 000000000..ec9e7b8af --- /dev/null +++ b/modules/keepass/KeepassPanel.qml @@ -0,0 +1,1048 @@ +pragma ComponentBehavior: Bound + +import qs +import qs.services +import qs.modules.common +import qs.modules.common.widgets +import qs.modules.common.functions +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Quickshell +import Quickshell.Io +import Quickshell.Wayland + +PanelWindow { + id: root + + WlrLayershell.namespace: "quickshell:keepass" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: KeePass.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + color: "transparent" + + anchors.top: true + anchors.left: true + anchors.right: true + anchors.bottom: true + + // Always-mapped surface: input gated by mask, never by visibility. + // Avoids Qt6 physicalDpiChanged infinite recursion on Hyprland surface remap. + mask: Region { item: KeePass.open ? backdropClickArea : noInputItem } + + Connections { + target: KeePass + function onOpenChanged() { + if (KeePass.open) panelColumn.focusDefault() + } + } + + Item { id: noInputItem; width: 0; height: 0 } + + // Click outside the panel to close + MouseArea { + id: backdropClickArea + anchors.fill: parent + acceptedButtons: Qt.LeftButton + onClicked: mouse => { + const pos = mapToItem(panelBackground, mouse.x, mouse.y) + const outside = pos.x < 0 || pos.x > panelBackground.width + || pos.y < 0 || pos.y > panelBackground.height + if (outside) KeePass.close() + else mouse.accepted = false + } + } + + StyledRectangularShadow { + target: panelBackground + radius: panelBackground.radius + visible: Appearance.angelEverywhere || (!Appearance.inirEverywhere && !Appearance.auroraEverywhere) + opacity: panelBackground.opacity + } + + GlassBackground { + id: panelBackground + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Appearance.sizes.elevationMargin + width: 600 + height: panelColumn.implicitHeight + 24 + radius: Appearance.angelEverywhere ? Appearance.angel.roundingLarge + : Appearance.inirEverywhere ? Appearance.inir.roundingLarge + : Appearance.rounding.screenRounding + fallbackColor: Appearance.colors.colLayer1 + inirColor: Appearance.inir.colLayer1 + auroraTransparency: Appearance.angelEverywhere + ? Appearance.angel.panelTransparentize + : Math.max(0.12, Appearance.aurora.subSurfaceTransparentize - 0.14) + border.width: 1 + border.color: Appearance.angelEverywhere ? Appearance.angel.colPanelBorder + : Appearance.inirEverywhere ? Appearance.inir.colBorder + : Appearance.auroraEverywhere ? Appearance.aurora.colTooltipBorder + : Appearance.colors.colOutlineVariant + screenX: (root.screen?.width ?? 1920) / 2 - width / 2 + screenY: Appearance.sizes.elevationMargin + screenWidth: root.screen?.width ?? 1920 + screenHeight: root.screen?.height ?? 1080 + + opacity: KeePass.open ? 1 : 0 + scale: KeePass.open ? 1 : 0.97 + + Behavior on opacity { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + Behavior on scale { + NumberAnimation { + duration: Appearance.animation.elementMoveFast.duration + easing.type: Appearance.animation.elementMoveFast.type + easing.bezierCurve: Appearance.animation.elementMoveFast.bezierCurve + } + } + + // Shared tokens for inner section cards — defined once to avoid 50+ repeated ternaries + readonly property color sectionColor: Appearance.angelEverywhere ? Appearance.angel.colGlassCard + : Appearance.inirEverywhere ? Appearance.inir.colLayer2 + : Appearance.auroraEverywhere ? Appearance.aurora.colElevatedSurface + : Appearance.colors.colLayer1 + readonly property color sectionBorder: Appearance.angelEverywhere ? Appearance.angel.colCardBorder + : Appearance.inirEverywhere ? Appearance.inir.colBorder + : Appearance.auroraEverywhere ? Appearance.aurora.colPopupBorder + : Appearance.colors.colOutlineVariant + readonly property real sectionRadius: Appearance.angelEverywhere ? Appearance.angel.roundingNormal + : Appearance.inirEverywhere ? Appearance.inir.roundingNormal + : Appearance.rounding.windowRounding + + ColumnLayout { + id: panelColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + focus: KeePass.open + + property bool showVaultPicker: !KeePass.vaultExists + onShowVaultPickerChanged: focusDefault() + + Connections { + target: KeePass + function onVaultExistsChanged() { + if (KeePass.vaultExists) panelColumn.showVaultPicker = false + } + function onOpenChanged() { + if (!KeePass.open) panelColumn.showVaultPicker = !KeePass.vaultExists + } + } + + function focusDefault() { + if (!KeePass.open) return + if (showVaultPicker) { + if (KeePass.availableVaults.length > 0) + Qt.callLater(() => vaultPickerList.forceActiveFocus()) + else + Qt.callLater(() => panelColumn.forceActiveFocus()) + return + } + if (!KeePass.unlocked) { + Qt.callLater(() => unlockPassword.forceActiveFocus()) + return + } + if (KeePass.addMode) { + Qt.callLater(() => panelColumn.forceActiveFocus()) + return + } + Qt.callLater(() => filterField.forceActiveFocus()) + } + + function cycleTab(direction) { + if (!KeePass.unlocked) return + const current = showVaultPicker ? 0 : (KeePass.addMode ? 2 : 1) + const next = (current + direction + 3) % 3 + if (next === 0) { + showVaultPicker = true + } else if (next === 1) { + showVaultPicker = false + KeePass.addMode = false + } else { + showVaultPicker = false + KeePass.addMode = true + } + } + + // Persist the chosen "stay unlocked" duration at the actual unlock + // (not live while dragging the slider), then unlock the vault. + function doUnlock(pw) { + Config.setNestedValue("keepass.cacheTtl", ttlSlider.value) + KeePass.unlock(pw) + } + + Connections { + target: KeePass + // Re-focus vault list once the async scan populates availableVaults. + // focusDefault() runs before the scan finishes (list is empty at that + // point) so it falls back to panelColumn. This corrects it. + function onAvailableVaultsChanged() { + if (KeePass.open && panelColumn.showVaultPicker && KeePass.availableVaults.length > 0) + Qt.callLater(() => vaultPickerList.forceActiveFocus()) + } + function onUnlockedChanged() { + panelColumn.focusDefault() + if (KeePass.unlocked && KeePass.addMode && KeePass.pendingPassword.length > 0) { + addPassword.text = KeePass.pendingPassword + KeePass.pendingPassword = "" + addPanel.addPasswordVisible = true + } + } + function onAddModeChanged() { + panelColumn.focusDefault() + if (KeePass.addMode && KeePass.unlocked && KeePass.pendingPassword.length > 0) { + addPassword.text = KeePass.pendingPassword + KeePass.pendingPassword = "" + addPanel.addPasswordVisible = true + } + } + function onGeneratedPasswordChanged() { + if (KeePass.generatedPassword.length > 0) { + addPassword.text = KeePass.generatedPassword + addPanel.addPasswordVisible = true + KeePass.generatedPassword = "" + } + } + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Escape) { + if (KeePass.selectedEntry.length > 0) KeePass.openEntry("") + else KeePass.close() + event.accepted = true + return + } + if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) { + panelColumn.cycleTab(event.key === Qt.Key_Right ? 1 : -1) + event.accepted = true + return + } + if (event.key === Qt.Key_Alt && !event.isAutoRepeat + && KeePass.unlocked && KeePass.selectedEntry.length > 0) { + if (!KeePass.reveal) KeePass.showPassword() + return + } + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { + if (!KeePass.unlocked) { + panelColumn.doUnlock(unlockPassword.text) + unlockPassword.text = "" + event.accepted = true + } else if (KeePass.addMode) { + KeePass.addEntry(addEntryName.text, addPassword.text, addUsername.text, addUrl.text) + addPanel.clearForm() + event.accepted = true + } else if (KeePass.selectedEntry.length > 0) { + KeePass.copyPassword() + event.accepted = true + } + } + } + Keys.onReleased: event => { + if (event.key === Qt.Key_Alt) { + KeePass.reveal = false + KeePass.revealedPassword = "" + } + } + + // ── Header ─────────────────────────────────────────────────────── + RowLayout { + Layout.fillWidth: true + spacing: 8 + StyledText { + text: KeePass.addMode ? Translation.tr("KeePass - Save") + : KeePass.vaultExists ? Translation.tr("KeePass · %1").arg(KeePass.vaultName) + : Translation.tr("KeePass") + font.pixelSize: Appearance.font.pixelSize.normal + color: Appearance.angelEverywhere ? Appearance.angel.colText + : Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 + : Appearance.colors.colOnLayer1 + } + + // Timer badge — click to reset + RippleButton { + visible: KeePass.unlocked + Layout.preferredHeight: 18 + Layout.preferredWidth: timerLabel.implicitWidth + 12 + buttonRadius: height / 2 + activeFocusOnTab: false + colBackground: Appearance.inirEverywhere ? Appearance.inir.colLayer0 : Appearance.colors.colLayer1 + onClicked: KeePass.remainingTime = KeePass.cacheTtl + + contentItem: Item { + anchors.fill: parent + + Rectangle { + anchors.left: parent.left + anchors.top: parent.top + anchors.bottom: parent.bottom + radius: height / 2 + width: parent.width * (KeePass.remainingTime / Math.max(1, KeePass.cacheTtl)) + color: KeePass.remainingTime < 30 + ? (Appearance.inirEverywhere ? Appearance.inir.colError : Appearance.colors.colError) + : (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + opacity: 0.2 + } + + StyledText { + id: timerLabel + anchors.centerIn: parent + text: "%1:%2".arg(Math.floor(KeePass.remainingTime / 60)).arg((KeePass.remainingTime % 60).toString().padStart(2, '0')) + font.pixelSize: Appearance.font.pixelSize.smallest + font.weight: Font.Bold + color: KeePass.remainingTime < 30 + ? (Appearance.inirEverywhere ? Appearance.inir.colError : Appearance.colors.colError) + : (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + } + } + } + + Item { Layout.fillWidth: true } + + // ── Nav: Browse entries ────────────────────────────────────── + RippleButton { + implicitWidth: 30 + implicitHeight: 30 + buttonRadius: Appearance.rounding.full + activeFocusOnTab: false + enabled: KeePass.vaultExists && KeePass.unlocked + opacity: enabled ? 1.0 : 0.3 + colBackground: (KeePass.vaultExists && KeePass.unlocked && !panelColumn.showVaultPicker && !KeePass.addMode) + ? (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + : "transparent" + onClicked: { panelColumn.showVaultPicker = false; KeePass.addMode = false } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "key" + iconSize: Appearance.font.pixelSize.larger + color: (KeePass.vaultExists && KeePass.unlocked && !panelColumn.showVaultPicker && !KeePass.addMode) + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 : Appearance.colors.colOnLayer1) + } + } + + // ── Nav: Add entry ─────────────────────────────────────────── + RippleButton { + implicitWidth: 30 + implicitHeight: 30 + buttonRadius: Appearance.rounding.full + activeFocusOnTab: false + enabled: KeePass.vaultExists && KeePass.unlocked + opacity: enabled ? 1.0 : 0.3 + colBackground: (KeePass.vaultExists && KeePass.unlocked && !panelColumn.showVaultPicker && KeePass.addMode) + ? (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + : "transparent" + onClicked: { panelColumn.showVaultPicker = false; KeePass.addMode = true } + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "edit_note" + iconSize: Appearance.font.pixelSize.larger + color: (KeePass.vaultExists && KeePass.unlocked && !panelColumn.showVaultPicker && KeePass.addMode) + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 : Appearance.colors.colOnLayer1) + } + } + + // ── Nav: Vault selector ────────────────────────────────────── + RippleButton { + implicitWidth: 30 + implicitHeight: 30 + buttonRadius: Appearance.rounding.full + activeFocusOnTab: false + colBackground: panelColumn.showVaultPicker + ? (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + : "transparent" + onClicked: panelColumn.showVaultPicker = !panelColumn.showVaultPicker + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: "folder_open" + iconSize: Appearance.font.pixelSize.larger + color: panelColumn.showVaultPicker + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 : Appearance.colors.colOnLayer1) + } + } + + RippleButton { + implicitWidth: 34 + implicitHeight: 34 + buttonRadius: Appearance.rounding.full + activeFocusOnTab: false + colBackground: "transparent" + onClicked: if (KeePass.unlocked) KeePass.lock() + contentItem: MaterialSymbol { + anchors.centerIn: parent + horizontalAlignment: Text.AlignHCenter + text: KeePass.unlocked ? "lock_open" : "lock" + iconSize: Appearance.font.pixelSize.larger + color: KeePass.unlocked + ? Appearance.m3colors.m3tertiary + : (Appearance.inirEverywhere ? Appearance.inir.colError : Appearance.colors.colError) + } + } + IconToolbarButton { + text: "close" + activeFocusOnTab: false + onClicked: KeePass.close() + } + } + + // ── Unavailable banner ─────────────────────────────────────────── + Rectangle { + visible: !KeePass.available + Layout.fillWidth: true + implicitHeight: unavailableColumn.implicitHeight + 16 + radius: panelBackground.sectionRadius + color: panelBackground.sectionColor + border.color: panelBackground.sectionBorder + + ColumnLayout { + id: unavailableColumn + anchors.fill: parent + anchors.margins: 12 + spacing: 6 + StyledText { + text: Translation.tr("keepassxc-cli not found") + font.pixelSize: Appearance.font.pixelSize.normal + font.weight: Font.Medium + color: Appearance.inirEverywhere ? Appearance.inir.colError : Appearance.colors.colError + } + StyledText { + Layout.fillWidth: true + text: Translation.tr("Install the keepassxc package to use this feature.") + font.pixelSize: Appearance.font.pixelSize.small + wrapMode: Text.WordWrap + color: Appearance.inirEverywhere ? Appearance.inir.colTextSecondary : Appearance.colors.colOnSurfaceVariant + } + } + } + + // ── Vault picker ───────────────────────────────────────────────── + Rectangle { + visible: KeePass.available && panelColumn.showVaultPicker && !KeePass.busy + Layout.fillWidth: true + implicitHeight: vaultPickerColumn.implicitHeight + 16 + radius: panelBackground.sectionRadius + color: panelBackground.sectionColor + border.color: panelBackground.sectionBorder + + ColumnLayout { + id: vaultPickerColumn + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + ColumnLayout { + visible: KeePass.availableVaults.length > 0 + Layout.fillWidth: true + spacing: 4 + + StyledText { + text: Translation.tr("Select a vault") + font.pixelSize: Appearance.font.pixelSize.small + font.weight: Font.Medium + color: Appearance.angelEverywhere ? Appearance.angel.colText + : Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 + : Appearance.colors.colOnLayer1 + } + + ListView { + id: vaultPickerList + Layout.fillWidth: true + implicitHeight: Math.min(260, contentHeight + 8) + clip: true + spacing: 4 + model: KeePass.availableVaults + keyNavigationEnabled: false + currentIndex: 0 + + highlightFollowsCurrentItem: true + highlightMoveDuration: 0 + highlight: Rectangle { + width: vaultPickerList.width + height: 36 + radius: height / 2 + color: Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary + } + + delegate: DialogListItem { + id: vaultDelegate + required property string modelData + readonly property bool isSelected: modelData === KeePass.vaultPath + width: vaultPickerList.width + implicitHeight: 36 + active: ListView.isCurrentItem + focus: false + activeFocusOnTab: false + colBackground: "transparent" + colBackgroundHover: Appearance.inirEverywhere ? Appearance.inir.colLayer1Hover : Appearance.colors.colLayer1Hover + contentItem: RowLayout { + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + MaterialSymbol { + text: (vaultDelegate.isSelected && KeePass.unlocked) ? "lock_open" : "lock" + iconSize: Appearance.font.pixelSize.normal + color: vaultDelegate.active + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : vaultDelegate.isSelected + ? (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colTextSecondary : Appearance.colors.colOnSurfaceVariant) + } + StyledText { + Layout.fillWidth: true + text: modelData.substring(modelData.lastIndexOf("/") + 1) + font.pixelSize: Appearance.font.pixelSize.small + color: vaultDelegate.active + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : vaultDelegate.isSelected + ? (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 : Appearance.colors.colOnLayer1) + elide: Text.ElideRight + } + } + onClicked: { + KeePass.selectVault(modelData) + panelColumn.showVaultPicker = false + } + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Down) { + currentIndex = Math.min(count - 1, currentIndex + 1) + event.accepted = true + } else if (event.key === Qt.Key_Up) { + currentIndex = Math.max(0, currentIndex - 1) + event.accepted = true + } else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && currentIndex >= 0 && count > 0) { + KeePass.selectVault(model[currentIndex]) + panelColumn.showVaultPicker = false + event.accepted = true + } else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) { + panelColumn.cycleTab(event.key === Qt.Key_Right ? 1 : -1) + event.accepted = true + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 1 + color: Appearance.inirEverywhere ? Appearance.inir.colBorder : Appearance.colors.colOutlineVariant + opacity: 0.5 + } + } + + StyledText { + text: Translation.tr("Create new vault") + font.pixelSize: Appearance.font.pixelSize.small + font.weight: Font.Medium + color: Appearance.angelEverywhere ? Appearance.angel.colText + : Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 + : Appearance.colors.colOnLayer1 + } + + ToolbarTextField { + id: newVaultName + Layout.fillWidth: true + placeholderText: Translation.tr("Vault name (e.g. personal)") + } + + ToolbarTextField { + id: newVaultPassword + Layout.fillWidth: true + placeholderText: Translation.tr("Password") + echoMode: TextInput.Password + } + + ToolbarTextField { + id: newVaultConfirm + Layout.fillWidth: true + placeholderText: Translation.tr("Confirm password") + echoMode: TextInput.Password + onAccepted: { + if (newVaultConfirm.text !== newVaultPassword.text) { + KeePass.lastError = Translation.tr("Passwords do not match") + return + } + KeePass.createVault(newVaultName.text, newVaultPassword.text) + newVaultName.text = "" + newVaultPassword.text = "" + newVaultConfirm.text = "" + } + } + + RowLayout { + spacing: 8 + DialogButton { + buttonText: Translation.tr("Create") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: { + if (newVaultConfirm.text !== newVaultPassword.text) { + KeePass.lastError = Translation.tr("Passwords do not match") + return + } + KeePass.createVault(newVaultName.text, newVaultPassword.text) + newVaultName.text = "" + newVaultPassword.text = "" + newVaultConfirm.text = "" + } + } + DialogButton { + buttonText: Translation.tr("Cancel") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: KeePass.close() + } + } + } + } + + // ── Unlock panel ───────────────────────────────────────────────── + Rectangle { + visible: KeePass.available && !panelColumn.showVaultPicker && KeePass.vaultExists && !KeePass.unlocked + Layout.fillWidth: true + implicitHeight: unlockColumn.implicitHeight + 16 + radius: panelBackground.sectionRadius + color: panelBackground.sectionColor + border.color: panelBackground.sectionBorder + + ColumnLayout { + id: unlockColumn + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + ToolbarTextField { + id: unlockPassword + Layout.fillWidth: true + placeholderText: Translation.tr("Vault password") + echoMode: TextInput.Password + onAccepted: { panelColumn.doUnlock(text); text = "" } + } + + RowLayout { + spacing: 8 + Layout.fillWidth: true + StyledText { + text: KeePass.unlocked + ? Translation.tr("Time left: %1:%2").arg(Math.floor(KeePass.remainingTime / 60)).arg((KeePass.remainingTime % 60).toString().padStart(2, '0')) + : Translation.tr("Stay unlocked: %1 min").arg(Math.floor(ttlSlider.value / 60)) + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.angelEverywhere ? Appearance.angel.colTextSecondary + : Appearance.inirEverywhere ? Appearance.inir.colTextSecondary + : Appearance.colors.colOnSurfaceVariant + } + } + + Slider { + id: ttlSlider + Layout.fillWidth: true + from: 60 + to: 14400 + stepSize: 60 + // Seeded from the persisted value; dragging only moves + // the handle — the value is committed on actual unlock. + value: KeePass.cacheTtl + + background: Rectangle { + x: ttlSlider.leftPadding + y: ttlSlider.topPadding + ttlSlider.availableHeight / 2 - height / 2 + implicitWidth: 200 + implicitHeight: 4 + width: ttlSlider.availableWidth + height: implicitHeight + radius: 2 + color: Appearance.inirEverywhere ? Appearance.inir.colLayer0 : Appearance.colors.colLayer1 + + Rectangle { + width: ttlSlider.visualPosition * parent.width + height: parent.height + color: Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary + radius: 2 + } + } + + handle: Rectangle { + x: ttlSlider.leftPadding + ttlSlider.visualPosition * (ttlSlider.availableWidth - width) + y: ttlSlider.topPadding + ttlSlider.availableHeight / 2 - height / 2 + implicitWidth: 16 + implicitHeight: 16 + radius: 8 + color: Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary + border.color: Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary + border.width: 1 + } + } + + RowLayout { + spacing: 8 + DialogButton { + buttonText: Translation.tr("Unlock") + activeFocusOnTab: false + onClicked: { panelColumn.doUnlock(unlockPassword.text); unlockPassword.text = "" } + } + DialogButton { + buttonText: Translation.tr("Cancel") + activeFocusOnTab: false + onClicked: KeePass.close() + } + } + } + } + + // ── Error ──────────────────────────────────────────────────────── + StyledText { + visible: KeePass.lastError.length > 0 + text: KeePass.lastError + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.inirEverywhere ? Appearance.inir.colError : Appearance.colors.colError + } + + // ── Entry list & detail ────────────────────────────────────────── + ColumnLayout { + visible: KeePass.available && !panelColumn.showVaultPicker && KeePass.vaultExists && KeePass.unlocked && !KeePass.addMode + spacing: 8 + + ToolbarTextField { + id: filterField + Layout.fillWidth: true + placeholderText: Translation.tr("Search entries") + onTextChanged: KeePass.filter = text + Keys.onPressed: event => { + if (event.key === Qt.Key_Down || event.key === Qt.Key_Enter || event.key === Qt.Key_Return) { + entryList.forceActiveFocus() + event.accepted = true + } + } + } + + ListView { + id: entryList + Layout.fillWidth: true + implicitHeight: Math.min(420, contentHeight + 8) + clip: true + spacing: 4 + model: KeePass.filteredEntries(KeePass.filter) + keyNavigationEnabled: false + currentIndex: 0 + + // Auto-select the highlighted entry while navigating so the + // detail card is already open: one Enter copies (no double + // press) and Alt reveals the entry you're looking at. + function selectCurrent() { + if (!activeFocus || currentIndex < 0 || currentIndex >= count) return + const entry = model[currentIndex] + if (entry !== undefined && KeePass.selectedEntry !== entry) + KeePass.openEntry(entry) + } + onActiveFocusChanged: if (activeFocus) selectCurrent() + + highlightFollowsCurrentItem: true + highlightMoveDuration: 0 + highlight: Rectangle { + width: entryList.width + height: 36 + radius: height / 2 + color: Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary + } + + delegate: DialogListItem { + id: listDelegate + required property var modelData + width: entryList.width + implicitHeight: 36 + active: ListView.isCurrentItem + focus: false + activeFocusOnTab: false + colBackground: "transparent" + colBackgroundHover: Appearance.inirEverywhere ? Appearance.inir.colLayer1Hover : Appearance.colors.colLayer1Hover + + contentItem: StyledText { + text: modelData + font.pixelSize: Appearance.font.pixelSize.small + anchors.fill: parent + anchors.leftMargin: 16 + anchors.rightMargin: 16 + verticalAlignment: Text.AlignVCenter + color: listDelegate.active + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 : Appearance.colors.colOnLayer1) + elide: Text.ElideRight + } + onClicked: KeePass.openEntry(modelData) + } + + Keys.onPressed: event => { + if (event.key === Qt.Key_Down) { + currentIndex = Math.min(count - 1, currentIndex + 1) + selectCurrent() + event.accepted = true + } else if (event.key === Qt.Key_Up) { + if (currentIndex <= 0) filterField.forceActiveFocus() + else { currentIndex = Math.max(0, currentIndex - 1); selectCurrent() } + event.accepted = true + } else if ((event.key === Qt.Key_Return || event.key === Qt.Key_Enter) && currentIndex >= 0) { + const entry = model[currentIndex] + if (KeePass.selectedEntry === entry) KeePass.copyPassword() + else KeePass.openEntry(entry) + event.accepted = true + } else if (event.key === Qt.Key_Left || event.key === Qt.Key_Right) { + panelColumn.cycleTab(event.key === Qt.Key_Right ? 1 : -1) + event.accepted = true + } else if (event.text.length > 0 && event.text.charCodeAt(0) >= 0x20) { + filterField.forceActiveFocus() + filterField.text += event.text + filterField.cursorPosition = filterField.text.length + event.accepted = true + } + } + } + + // Entry detail card + Rectangle { + visible: KeePass.selectedEntry.length > 0 + Layout.fillWidth: true + implicitHeight: detailColumn.implicitHeight + 16 + radius: panelBackground.sectionRadius + color: panelBackground.sectionColor + border.color: panelBackground.sectionBorder + + ColumnLayout { + id: detailColumn + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + StyledText { + Layout.fillWidth: true + text: KeePass.selectedEntry + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.angelEverywhere ? Appearance.angel.colText + : Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 + : Appearance.colors.colOnLayer1 + elide: Text.ElideRight + } + RippleButton { + implicitWidth: 28 + implicitHeight: 28 + buttonRadius: Appearance.rounding.full + colBackground: "transparent" + onClicked: KeePass.openEntry("") + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: "close" + iconSize: Appearance.font.pixelSize.normal + color: Appearance.angelEverywhere ? Appearance.angel.colTextSecondary + : Appearance.inirEverywhere ? Appearance.inir.colTextSecondary + : Appearance.colors.colOnSurfaceVariant + } + } + } + + Rectangle { + Layout.fillWidth: true + implicitHeight: 36 + radius: Appearance.rounding.full + color: Appearance.angelEverywhere ? Appearance.angel.colGlassPanel + : Appearance.inirEverywhere ? Appearance.inir.colLayer0 + : Appearance.auroraEverywhere ? Appearance.aurora.colOverlay + : Appearance.colors.colLayer0 + border.color: Appearance.angelEverywhere ? Appearance.angel.colCardBorder + : Appearance.inirEverywhere ? Appearance.inir.colBorder + : Appearance.auroraEverywhere ? Appearance.aurora.colPopupBorder + : Appearance.colors.colLayer0Border + + TextEdit { + anchors.centerIn: parent + text: KeePass.reveal ? KeePass.revealedPassword : "••••••••••" + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.angelEverywhere ? Appearance.angel.colText + : Appearance.inirEverywhere ? Appearance.inir.colOnLayer0 + : Appearance.colors.colOnLayer0 + selectByMouse: true + readOnly: true + wrapMode: TextEdit.NoWrap + } + } + + RowLayout { + spacing: 8 + DialogButton { + buttonText: KeePass.reveal ? Translation.tr("Hide") : Translation.tr("Show") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: { + if (KeePass.reveal) { KeePass.reveal = false; KeePass.revealedPassword = "" } + else KeePass.showPassword() + } + } + DialogButton { + buttonText: Translation.tr("Copy Password") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: KeePass.copyPassword() + } + DialogButton { + buttonText: Translation.tr("Copy Username") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: KeePass.copyUsername() + } + } + } + } + } + + // ── Add entry panel ────────────────────────────────────────────── + ColumnLayout { + id: addPanel + visible: KeePass.available && !panelColumn.showVaultPicker && KeePass.vaultExists && KeePass.unlocked && KeePass.addMode + spacing: 8 + + property bool addPasswordVisible: false + property int genLength: 20 + property bool genUppercase: true + property bool genNumbers: true + property bool genSymbols: true + property bool genWords: false + + function clearForm() { + addEntryName.text = "" + addUsername.text = "" + addUrl.text = "" + addPassword.text = "" + addPasswordVisible = false + } + + ToolbarTextField { id: addEntryName; Layout.fillWidth: true; placeholderText: Translation.tr("Entry name (e.g. Email/GitHub)") } + ToolbarTextField { id: addUsername; Layout.fillWidth: true; placeholderText: Translation.tr("Username (optional)") } + ToolbarTextField { id: addUrl; Layout.fillWidth: true; placeholderText: Translation.tr("URL (optional)") } + + RowLayout { + Layout.fillWidth: true + spacing: 4 + ToolbarTextField { + id: addPassword + Layout.fillWidth: true + placeholderText: Translation.tr("Password") + echoMode: addPanel.addPasswordVisible ? TextInput.Normal : TextInput.Password + onAccepted: { + KeePass.addEntry(addEntryName.text, text, addUsername.text, addUrl.text) + addPanel.clearForm() + } + } + RippleButton { + implicitWidth: 34; implicitHeight: 34 + buttonRadius: Appearance.rounding.full + colBackground: "transparent" + onClicked: addPanel.addPasswordVisible = !addPanel.addPasswordVisible + contentItem: MaterialSymbol { + anchors.centerIn: parent + text: addPanel.addPasswordVisible ? "visibility_off" : "visibility" + iconSize: Appearance.font.pixelSize.larger + color: Appearance.angelEverywhere ? Appearance.angel.colText + : Appearance.inirEverywhere ? Appearance.inir.colOnLayer1 + : Appearance.colors.colOnLayer1 + } + } + DialogButton { + buttonText: Translation.tr("Generate") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: KeePass.generate(addPanel.genLength, addPanel.genUppercase, addPanel.genNumbers, addPanel.genSymbols, addPanel.genWords) + } + } + + RowLayout { + spacing: 4 + StyledText { + text: Translation.tr("Len:") + font.pixelSize: Appearance.font.pixelSize.small + color: Appearance.angelEverywhere ? Appearance.angel.colTextSecondary + : Appearance.inirEverywhere ? Appearance.inir.colTextSecondary + : Appearance.colors.colOnSurfaceVariant + } + Repeater { + model: [8, 12, 20] + delegate: DialogButton { + id: lenBtn + required property int modelData + buttonText: modelData.toString() + activeFocusOnTab: false + buttonRadius: height / 2 + toggled: addPanel.genLength === modelData + colBackgroundToggled: Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary + contentItem: StyledText { + text: lenBtn.buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.small + color: lenBtn.toggled + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + } + onClicked: addPanel.genLength = modelData + } + } + Item { implicitWidth: 8 } + Repeater { + model: [ + { label: "A-Z", prop: "genUppercase" }, + { label: "0-9", prop: "genNumbers" }, + { label: "!@#", prop: "genSymbols" }, + { label: Translation.tr("words"), prop: "genWords" }, + ] + delegate: DialogButton { + id: toggleBtn + required property var modelData + buttonText: modelData.label + activeFocusOnTab: false + buttonRadius: height / 2 + toggled: addPanel[modelData.prop] + colBackgroundToggled: Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary + contentItem: StyledText { + text: toggleBtn.buttonText + horizontalAlignment: Text.AlignHCenter + font.pixelSize: Appearance.font.pixelSize.small + color: toggleBtn.toggled + ? (Appearance.inirEverywhere ? Appearance.inir.colOnPrimary : Appearance.colors.colOnPrimary) + : (Appearance.inirEverywhere ? Appearance.inir.colPrimary : Appearance.colors.colPrimary) + } + onClicked: addPanel[modelData.prop] = !addPanel[modelData.prop] + } + } + } + + RowLayout { + spacing: 8 + DialogButton { + buttonText: Translation.tr("Save") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: { + KeePass.addEntry(addEntryName.text, addPassword.text, addUsername.text, addUrl.text) + addPanel.clearForm() + } + } + DialogButton { + buttonText: Translation.tr("Cancel") + activeFocusOnTab: false + buttonRadius: height / 2 + onClicked: KeePass.close() + } + } + } + } + } +} diff --git a/scripts/lib/ipc-registry.sh b/scripts/lib/ipc-registry.sh index b9b271f01..aa94e813e 100755 --- a/scripts/lib/ipc-registry.sh +++ b/scripts/lib/ipc-registry.sh @@ -2,8 +2,8 @@ # Auto-generated from QML IpcHandler declarations + docs/IPC.md metadata. # Do not edit manually. # Regenerate: python3 scripts/lib/generate-ipc-registry.py -# IPC.md hash: 0fcb5c3ba72f0099 -# Targets: 51 +# IPC.md hash: fef4bfa58f886e6d +# Targets: 52 declare -gA IPC_TARGET_DESC=( [ai]="AI chat service. Multi-provider (Gemini, OpenAI, Mistral) with tool support." @@ -22,6 +22,7 @@ declare -gA IPC_TARGET_DESC=( [customWidgets]="Custom widget management. Create, list, reload, and remove user-installed widgets from \`~/.config/inir/widgets/\`." [gamemode]="Performance mode for gaming. Auto-detects fullscreen apps and disables animations/effects. Can also be toggled manually for those stubborn games that don't go fullscreen properly." [globalActions]="Command palette / action registry. Search and execute shell actions from scripts or keybinds." + [keepass]="KeePass password manager overlay. Search entries, copy passwords/usernames, and add new entries." [keyboard]="Keyboard layout switching (Niri only). Cycles through configured keyboard layouts and queries layout info." [lock]="Lock screen. For when you need to pretend you're working." [mediaControls]="Floating media controls panel." @@ -76,6 +77,7 @@ declare -gA IPC_TARGET_FAMILY=( [customWidgets]="waffle" [gamemode]="shared" [globalActions]="shared" + [keepass]="shared" [keyboard]="shared" [lock]="shared" [mediaControls]="shared" @@ -130,6 +132,7 @@ declare -gA IPC_TARGET_FUNCTIONS=( [customWidgets]="reload list create remove" [gamemode]="toggle activate deactivate status" [globalActions]="run runWithArgs list search open" + [keepass]="toggle add" [keyboard]="switchLayout switchLayoutPrevious getCurrentLayout getLayouts" [lock]="activate deactivate status focus" [mediaControls]="toggle close open" @@ -219,6 +222,8 @@ declare -gA IPC_FUNCTION_DESC=( ["globalActions:list"]="List all actions, optionally filtered by category" ["globalActions:search"]="Fuzzy search actions by name/description/keywords" ["globalActions:open"]="Open the overview in action mode" + ["keepass:toggle"]="Open/close KeePass panel" + ["keepass:add"]="Open panel in \"add entry\" mode, pre-filling title from primary selection" ["keyboard:switchLayout"]="Switch to next keyboard layout" ["keyboard:switchLayoutPrevious"]="Switch to previous keyboard layout" ["keyboard:getCurrentLayout"]="Get the current layout name" @@ -271,7 +276,7 @@ declare -gA IPC_FUNCTION_DESC=( ["region:ocr"]="OCR text recognition" ["region:record"]="Record region (no audio)" ["region:recordWithSound"]="Record region with audio" - ["region:menu"]="Open the unified snip menu (pick action/scope inline)" + ["region:menu"]="" ["search:toggle"]="Open/close start menu" ["search:close"]="Close start menu" ["search:open"]="Open start menu" @@ -366,6 +371,8 @@ bind "Alt+Shift+Tab" { spawn "inir" "altSwitcher" "previous"; }' [gamemode]='bind "Super+F12" { spawn "inir" "gamemode" "toggle"; }' [globalActions]='bind "Super+Slash" { spawn "inir" "globalActions" "open"; } bind "Super+M" { spawn "inir" "globalActions" "run" "toggle-mute"; }' + [keepass]='bind "Mod+P" { spawn "inir" "keepass" "toggle"; } +bind "Mod+Ctrl+P" { spawn "inir" "keepass" "add"; }' [keyboard]='bind "Mod+Alt+K" { spawn "inir" "keyboard" "switchLayout"; }' [lock]='bind "Super+Alt+L" allow-when-locked=true { spawn "inir" "lock" "activate"; }' [mpris]='bind "Ctrl+Mod+Space" { spawn "inir" "mpris" "playPause"; } @@ -376,8 +383,7 @@ bind "Mod+Alt+P" { spawn "inir" "mpris" "previous"; }' [panelFamily]='bind "Mod+Shift+W" { spawn "inir" "panelFamily" "cycle"; }' [region]='bind "Super+Shift+S" { spawn "inir" "region" "screenshot"; } bind "Super+Shift+X" { spawn "inir" "region" "ocr"; } -bind "Super+Shift+A" { spawn "inir" "region" "search"; } -bind "Ctrl+Shift+S" { spawn "inir" "region" "menu"; }' +bind "Super+Shift+A" { spawn "inir" "region" "search"; }' [session]='bind "Super+Shift+E" { spawn "inir" "session" "toggle"; }' [settings]='bind "Super+Comma" { spawn "inir" "settings"; }' [voiceSearch]='bind "Super+Shift+V" { spawn "inir" "voiceSearch" "toggle"; }' @@ -385,8 +391,8 @@ bind "Ctrl+Shift+S" { spawn "inir" "region" "menu"; }' [ytmusic]='bind "Mod+M+Space" { spawn "inir" "ytmusic" "playPause"; }' ) -IPC_ALL_TARGETS=(ai altSwitcher appCatalog audio background bar brightness cheatsheet clipboard cliphistService closeConfirm controlPanel coverflowSelector customWidgets gamemode globalActions keyboard lock mediaControls memory minimize mpris notifications osd osdVolume osk overlay overview packageSearch panelFamily recordingOsd region search session settings settingsNav shellUpdate sidebarLeft sidebarRight taskview tiling voiceSearch wactionCenter waffleAltSwitcher wallpaperSelector wbar widgetpower wnotificationCenter wwidgets ytmusic zoom) -IPC_SHARED_TARGETS=(ai altSwitcher appCatalog audio bar brightness cheatsheet clipboard cliphistService closeConfirm controlPanel coverflowSelector gamemode globalActions keyboard lock mediaControls memory minimize mpris notifications osdVolume osk overview packageSearch panelFamily region session settings settingsNav shellUpdate sidebarLeft sidebarRight tiling voiceSearch wallpaperSelector ytmusic zoom) +IPC_ALL_TARGETS=(ai altSwitcher appCatalog audio background bar brightness cheatsheet clipboard cliphistService closeConfirm controlPanel coverflowSelector customWidgets gamemode globalActions keepass keyboard lock mediaControls memory minimize mpris notifications osd osdVolume osk overlay overview packageSearch panelFamily recordingOsd region search session settings settingsNav shellUpdate sidebarLeft sidebarRight taskview tiling voiceSearch wactionCenter waffleAltSwitcher wallpaperSelector wbar widgetpower wnotificationCenter wwidgets ytmusic zoom) +IPC_SHARED_TARGETS=(ai altSwitcher appCatalog audio bar brightness cheatsheet clipboard cliphistService closeConfirm controlPanel coverflowSelector gamemode globalActions keepass keyboard lock mediaControls memory minimize mpris notifications osdVolume osk overview packageSearch panelFamily region session settings settingsNav shellUpdate sidebarLeft sidebarRight tiling voiceSearch wallpaperSelector ytmusic zoom) IPC_II_TARGETS=(overlay) IPC_WAFFLE_TARGETS=(background customWidgets osd recordingOsd search taskview wactionCenter waffleAltSwitcher wbar widgetpower wnotificationCenter wwidgets) diff --git a/scripts/quickshell-keepass b/scripts/quickshell-keepass new file mode 100755 index 000000000..17af35585 --- /dev/null +++ b/scripts/quickshell-keepass @@ -0,0 +1,458 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v keepassxc-cli >/dev/null 2>&1; then + echo "quickshell-keepass: keepassxc-cli not found — install keepassxc to use this feature" >&2 + exit 127 +fi + +VAULT_PATH="${KP_VAULT_PATH:-$HOME/.local/share/keepassqs/vault.kdbx}" +NONINTERACTIVE="${KP_NONINTERACTIVE:-0}" + +# Secret Service attributes +SECRET_LABEL="iNiR KeePass Vault" + +# Optional trace, off unless KP_DEBUG is set. Every keyring decision (cache +# hit/miss, store, clear, stdin fallback) is logged so there are no silent +# fallbacks to wonder about. Zero overhead when KP_DEBUG is unset. +KP_DEBUG_LOG="${KP_DEBUG_LOG:-${XDG_RUNTIME_DIR:-/tmp}/quickshell-keepass-debug.log}" +kp_debug() { + [[ -n "${KP_DEBUG:-}" ]] || return 0 + printf '%s [%s] %s\n' "$(date '+%H:%M:%S')" "${KP_CMD:-?}" "$*" >> "$KP_DEBUG_LOG" 2>/dev/null || true +} + +die() { + echo "quickshell-keepass: $*" >&2 + exit 1 +} + +# --- Cache Management (using secret-tool) --- + +# Cache items are scoped per vault via an extra `path` attribute so that vaults +# with different master passwords never feed each other a stale password. The +# base `inir keepass` attributes are shared, so a path-less clear (clear-all) +# still wipes every vault's item in one go. +write_cache() { + local password="$1" + command -v secret-tool >/dev/null 2>&1 || { kp_debug "write_cache: SKIPPED (no secret-tool)"; return 0; } + local -a attrs=(inir keepass) + [[ -n "${VAULT_PATH:-}" ]] && attrs+=(path "$VAULT_PATH") + printf '%s' "$password" | secret-tool store --label="$SECRET_LABEL" "${attrs[@]}" + kp_debug "write_cache: stored in keyring (path=$VAULT_PATH)" +} + +read_cache() { + command -v secret-tool >/dev/null 2>&1 || return 0 + local -a attrs=(inir keepass) + [[ -n "${VAULT_PATH:-}" ]] && attrs+=(path "$VAULT_PATH") + secret-tool lookup "${attrs[@]}" 2>/dev/null || true +} + +clear_cache() { + command -v secret-tool >/dev/null 2>&1 || return 0 + local -a attrs=(inir keepass) + [[ -n "${VAULT_PATH:-}" ]] && attrs+=(path "$VAULT_PATH") + secret-tool clear "${attrs[@]}" 2>/dev/null || true + kp_debug "clear_cache: cleared keyring item (path=$VAULT_PATH)" +} + +# Drop every iNiR keepass cache item regardless of vault (used on shell start to +# make sure no master password lingers from a previous session or a crash). +clear_cache_all() { + command -v secret-tool >/dev/null 2>&1 || return 0 + secret-tool clear inir keepass 2>/dev/null || true + kp_debug "clear_cache_all: wiped all iNiR keepass items" +} + +# Report whether a working Secret Service is reachable. Installed-but-no-daemon +# is common, so this does a real store/lookup/clear round-trip rather than just +# checking for the binary. +check_keyring() { + command -v secret-tool >/dev/null 2>&1 || { echo "no"; return 0; } + # Distinct attribute pair (inir=keepass-probe) so the probe never collides + # with real cache items (inir=keepass) or clear-all. + if printf '%s' "probe" | secret-tool store --label="iNiR KeePass probe" inir keepass-probe >/dev/null 2>&1 \ + && [[ "$(secret-tool lookup inir keepass-probe 2>/dev/null)" == "probe" ]]; then + secret-tool clear inir keepass-probe >/dev/null 2>&1 || true + kp_debug "check_keyring: yes (Secret Service reachable)" + echo "yes" + else + kp_debug "check_keyring: no (no usable Secret Service)" + echo "no" + fi +} + +# --- Core Logic --- + +read_password() { + local password + password=$(read_cache) + if [[ -n "$password" ]]; then + kp_debug "read_password: keyring cache HIT (path=$VAULT_PATH)" + printf '%s' "$password" + return 0 + fi + + if [[ "$NONINTERACTIVE" == "1" || ! -t 0 ]]; then + kp_debug "read_password: cache MISS -> reading from stdin (in-memory fallback / fresh unlock)" + IFS= read -r password || die "locked (no cached password and stdin empty)" + [[ -n "$password" ]] || die "locked (empty password)" + else + kp_debug "read_password: cache MISS -> interactive prompt" + read -r -s -p "Vault password: " password + echo >&2 + [[ -n "$password" ]] || die "empty password" + fi + printf '%s' "$password" +} + +require_vault() { + [[ -f "$VAULT_PATH" ]] || die "vault not found: $VAULT_PATH (run: quickshell-keepass init)" +} + +# Wrapper for keepassxc-cli with better error reporting +kp_exec() { + local cmd="$1" + shift + local password + password="$(read_password)" + + local out err exit_code + local err_file + err_file=$(mktemp) + + # Use printf -- to handle passwords starting with - + if printf -- '%s\n' "$password" | keepassxc-cli "$cmd" -q "$VAULT_PATH" "$@" 2>"$err_file"; then + # Success: update cache if it wasn't there + [[ -z "$(read_cache)" ]] && write_cache "$password" + rm -f "$err_file" + return 0 + else + exit_code=$? + err=$(<"$err_file") + rm -f "$err_file" + + if [[ "$err" == *"Invalid password"* || "$err" == *"password is incorrect"* ]]; then + clear_cache + die "Invalid password" + elif [[ "$err" == *"Database is already locked"* ]]; then + die "Database is locked by another process" + elif [[ -n "$err" ]]; then + die "Error ($exit_code): $err" + else + die "Command failed with code $exit_code" + fi + fi +} + +# --- Commands --- + +cmd_init() { + [[ -f "$VAULT_PATH" ]] && die "vault already exists: $VAULT_PATH" + mkdir -p "$(dirname "$VAULT_PATH")" + local password + read -r -s -p "New vault password: " password + echo >&2 + [[ -n "$password" ]] || die "empty password" + printf -- '%s\n%s\n' "$password" "$password" | keepassxc-cli db-create -q -p "$VAULT_PATH" + write_cache "$password" + echo "Created vault: $VAULT_PATH" +} + +cmd_create() { + [[ -f "$VAULT_PATH" ]] && die "vault already exists: $VAULT_PATH" + mkdir -p "$(dirname "$VAULT_PATH")" + local password + IFS= read -r password || die "empty password" + [[ -n "$password" ]] || die "empty password" + printf -- '%s\n%s\n' "$password" "$password" | keepassxc-cli db-create -q -p "$VAULT_PATH" + write_cache "$password" +} + +cmd_lock() { + clear_cache + echo "Locked (cache cleared)." +} + +cmd_unlock() { + # Just try to list to verify password and set cache + if kp_exec ls -f / >/dev/null; then + echo "Unlocked (cache set)." + fi +} + +cmd_status() { + if [[ -n "$(read_cache)" ]]; then + echo "unlocked (cached in keyring)" + else + echo "locked" + fi +} + +cmd_ls() { + require_vault + kp_exec ls "$@" +} + +cmd_show() { + require_vault + kp_exec show "$@" +} + +cmd_get() { + require_vault + [[ $# -ge 2 ]] || die "usage: get " + kp_exec show -s -a "$2" "$1" +} + +cmd_clip() { + require_vault + [[ $# -ge 2 ]] || die "usage: clip " + kp_exec clip -a "$2" "$1" +} + +cmd_search() { + require_vault + [[ $# -ge 1 ]] || die "usage: search " + kp_exec search "$@" +} + +cmd_add() { + require_vault + [[ $# -ge 1 ]] || die "usage: add [username] [url]" + local entry="$1" + local username="${2:-}" + local url="${3:-}" + + local db_password + db_password="$(read_password)" + + local entry_password="${KP_ENTRY_PASSWORD:-}" + if [[ -z "$entry_password" ]]; then + if [[ "$NONINTERACTIVE" == "1" || ! -t 0 ]]; then + IFS= read -r entry_password || die "missing entry password" + else + read -r -s -p "Entry password: " entry_password + echo >&2 + fi + fi + [[ -n "$entry_password" ]] || die "empty entry password" + + local group="${entry%/*}" + if [[ "$group" != "$entry" && -n "$group" && "$group" != "/" ]]; then + printf -- '%s\n' "$db_password" | keepassxc-cli mkdir -q "$VAULT_PATH" "$group" >/dev/null 2>&1 || true + fi + + local args=("add" "-q") + [[ -n "$username" ]] && args+=("-u" "$username") + [[ -n "$url" ]] && args+=("--url" "$url") + args+=("--password-prompt" "$VAULT_PATH" "$entry") + + if printf -- '%s\n%s\n' "$db_password" "$entry_password" | keepassxc-cli "${args[@]}"; then + # Refresh the cache only if it was missing. This must not be the last + # statement via `&& write_cache`: when the cache already exists the test is + # false and the function would return 1, making a successful add look failed. + if [[ -z "$(read_cache)" ]]; then + write_cache "$db_password" + fi + return 0 + else + die "Failed to add entry" + fi +} + +# --- Password Generation --- + +_KP_WORDS_EN=( + apple tiger river stone cloud forest bridge candle socket hammer + spider monkey island castle mirror thunder blanket garden pencil anchor + butter coffee pepper winter autumn needle rocket planet silver copper + violin pickle cactus jungle lantern oyster falcon rocket walnut shadow + marble crimson falcon jungle oyster pepper rabbit silver socket spider + cable carrot pillow saddle cannon barrel circus clover donkey fossil + gravel kettle locket marble napkin parrot quartz ribbon salmon tablet + tunnel velvet walrus bamboo canopy debris ferret gravel hollow insect + jigsaw kernel lagoon muffin noodle origami paddle quiver riddle spiral + tundra uplift vessel wiggle xylem yellow zipper biscuit clutter double +) + +_kp_rand_u16() { od -An -tu2 -N2 /dev/urandom | tr -d ' \n'; } +_kp_rand_u8() { od -An -tu1 -N1 /dev/urandom | tr -d ' \n'; } + +cmd_generate() { + local use_upper="${KP_GEN_UPPER:-1}" + local use_numbers="${KP_GEN_NUMBERS:-1}" + local use_symbols="${KP_GEN_SYMBOLS:-1}" + local length="${KP_GEN_LENGTH:-20}" + + if [[ "${KP_GEN_WORDS:-0}" == "1" ]]; then + local -a words + if [[ -n "${KP_GEN_WORDLIST:-}" ]]; then + read -ra words <<< "$KP_GEN_WORDLIST" + else + words=("${_KP_WORDS_EN[@]}") + fi + + # Length preset becomes a word-count proxy: short=2, medium=3, long=4 + local num_words=3 + if [[ "$length" -le 8 ]]; then num_words=2 + elif [[ "$length" -gt 14 ]]; then num_words=4 + fi + + local -a sel=() + local i + for ((i=0; i + quickshell-keepass show [attributes...] + quickshell-keepass get + quickshell-keepass add [username] [url] + quickshell-keepass generate + +Env: + KP_VAULT_PATH Vault path (default: $VAULT_PATH) + KP_NONINTERACTIVE Set to 1 to read password from stdin +EOF +} + +main() { + local cmd="${1:-help}" + shift || true + KP_CMD="$cmd" + kp_debug "invoked: $cmd $* (vault=$VAULT_PATH, noninteractive=$NONINTERACTIVE)" + case "$cmd" in + init) cmd_init "$@" ;; + create) cmd_create ;; + status) cmd_status ;; + lock) cmd_lock ;; + clear-all) clear_cache_all ;; + check-keyring) check_keyring ;; + unlock) cmd_unlock ;; + ls|list) cmd_ls "$@" ;; + search) cmd_search "$@" ;; + show) cmd_show "$@" ;; + get) cmd_get "$@" ;; + clip) cmd_clip "$@" ;; + add) cmd_add "$@" ;; + generate) cmd_generate ;; + help|-h|--help) cmd_help ;; + *) die "unknown command: $cmd" ;; + esac +} + +main "$@" diff --git a/sdata/dist-arch/inir-deps/PKGBUILD b/sdata/dist-arch/inir-deps/PKGBUILD index 62c5d9386..d10069f64 100644 --- a/sdata/dist-arch/inir-deps/PKGBUILD +++ b/sdata/dist-arch/inir-deps/PKGBUILD @@ -157,6 +157,7 @@ optdepends=( 'songrec: song recognition' 'translate-shell: translation widget' 'hyprpicker: color picker' + 'keepassxc: password manager overlay (keepassxc-cli)' 'python-evdev: input device access' 'python-pillow: image processing in scripts' 'darkly-bin: Material You Qt style' diff --git a/services/KeePass.qml b/services/KeePass.qml new file mode 100644 index 000000000..7e5ca8e75 --- /dev/null +++ b/services/KeePass.qml @@ -0,0 +1,509 @@ +pragma Singleton +pragma ComponentBehavior: Bound + +import qs +import qs.modules.common +import qs.modules.common.functions +import qs.services +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + + property string scriptPath: FileUtils.trimFileProtocol(`${Directories.scriptsPath}/quickshell-keepass`) + readonly property string vaultDir: Config.options?.keepass?.vaultDir + || FileUtils.trimFileProtocol(`${Directories.home}/.local/share/keepassqs`) + property string vaultPath: "" + readonly property string vaultName: { + if (vaultPath.length === 0) return "" + const n = vaultPath.split("/").pop() + return n.endsWith(".kdbx") ? n.slice(0, -5) : n + } + + property bool available: true + property bool open: false + property bool vaultExists: false + property list availableVaults: [] + property bool addMode: false + property bool unlocked: false + property bool busy: false + property string lastError: "" + property string pendingPassword: "" + + property string password: "" + property list entries: [] + property string filter: "" + + property string selectedEntry: "" + property bool reveal: false + property string revealedPassword: "" + property string generatedPassword: "" + + // Persisted in config so the "stay unlocked" duration survives restarts. + readonly property int cacheTtl: Config.options?.keepass?.cacheTtl ?? 300 + property int remainingTime: 0 + + // Whether a usable Secret Service is reachable. When false, the master + // password is kept in memory for the session so copy/reveal still work + // (probed once at startup; assume available until proven otherwise). + property bool keyringAvailable: true + + Timer { + id: lockTimer + interval: 1000 + repeat: true + running: root.unlocked && root.remainingTime > 0 + onTriggered: { + root.remainingTime--; + if (root.remainingTime <= 0) { + root.lock(); + } + } + } + + function resetSensitive() { + password = "" + unlocked = false + entries = [] + selectedEntry = "" + reveal = false + revealedPassword = "" + lastError = "" + } + + function scanVaults() { + scanProc.exec(["find", root.vaultDir, "-maxdepth", "1", "-name", "*.kdbx", "-type", "f"]) + } + + function selectVault(path) { + if (vaultPath.length > 0 && path !== vaultPath) { + lockProc.exec({ + environment: { KP_VAULT_PATH: root.vaultPath }, + command: [scriptPath, "lock"] + }) + } + vaultPath = path + vaultExists = true + resetSensitive() + } + + function createVault(name, passwordValue) { + if (!name || name.trim().length === 0) { + lastError = Translation.tr("Missing vault name") + return + } + if (!passwordValue || passwordValue.trim().length === 0) { + lastError = Translation.tr("Missing password") + return + } + const safeName = name.trim().replace(/[^a-zA-Z0-9_\-]/g, "_") + const targetPath = root.vaultDir + "/" + safeName + ".kdbx" + busy = true + lastError = "" + createProc.targetPath = targetPath + createProc.exec({ + environment: { KP_VAULT_PATH: targetPath }, + command: [scriptPath, "create"] + }) + createProc.write(passwordValue + "\n") + } + + function openList() { + open = true + addMode = false + scanVaults() + } + + function openAdd() { + open = true + addMode = true + scanVaults() + } + + function close() { + open = false + addMode = false + pendingPassword = "" + // Don't resetSensitive here, let it stay unlocked in background + } + + function openAddWithSelection() { + if (root.open && addMode) { + close() + return + } + selectionProc.exec(["wl-paste"]) + } + + function lock() { + lockProc.exec({ + environment: { KP_VAULT_PATH: root.vaultPath }, + command: [scriptPath, "lock"] + }) + root.remainingTime = 0 + resetSensitive() + } + + function envFor() { + return { + KP_VAULT_PATH: root.vaultPath, + KP_NONINTERACTIVE: "1" + } + } + + function unlock(passwordValue) { + if (!passwordValue || passwordValue.trim().length === 0) { + lastError = Translation.tr("Missing password") + return + } + password = passwordValue + refreshEntries() + } + + function refreshEntries() { + busy = true + listProc.buffer = [] + listProc.exec({ + environment: envFor(), + command: [scriptPath, "ls", "-R", "-f"] + }) + } + + function filteredEntries(query) { + const q = (query ?? "").trim().toLowerCase() + if (q.length === 0) return entries + return entries.filter(e => e.toLowerCase().includes(q)) + } + + function openEntry(entry) { + selectedEntry = entry + reveal = false + revealedPassword = "" + } + + function showPassword() { + if (!selectedEntry) return + getProc.exec({ + environment: envFor(), + command: [scriptPath, "get", selectedEntry, "password"] + }) + } + + function copyPassword() { + if (!selectedEntry) return + copyGetProc.exec({ + environment: envFor(), + command: [scriptPath, "get", selectedEntry, "password"] + }) + } + + function copyUsername() { + if (!selectedEntry) return + copyUsernameProc.exec({ + environment: envFor(), + command: [scriptPath, "get", selectedEntry, "username"] + }) + } + + function generate(length, useUpper, useNumbers, useSymbols, useWords) { + genProc.exec({ + environment: { + KP_GEN_LENGTH: length.toString(), + KP_GEN_UPPER: useUpper ? "1" : "0", + KP_GEN_NUMBERS: useNumbers ? "1" : "0", + KP_GEN_SYMBOLS: useSymbols ? "1" : "0", + KP_GEN_WORDS: useWords ? "1" : "0", + KP_GEN_WORDLIST: Translation.tr("keepass_wordlist") + }, + command: [scriptPath, "generate"] + }) + } + + function addEntry(entry, entryPassword, username, url) { + if (!entry || entry.trim().length === 0 || !entryPassword) { + lastError = Translation.tr("Missing entry name or password") + return + } + addProc.entryPassword = entryPassword + addProc.exec({ + environment: envFor(), + command: [scriptPath, "add", entry, username ?? "", url ?? ""] + }) + } + + Component.onCompleted: { + availabilityProc.exec(["sh", "-c", "command -v keepassxc-cli >/dev/null 2>&1"]) + keyringProbeProc.exec({ command: [scriptPath, "check-keyring"] }) + // Drop any master password left in the keyring by a previous session or + // a crash: the shell always starts locked, so a cached secret would be + // dead weight (there is no auto-unlock from the keyring). + staleClearProc.exec({ command: [scriptPath, "clear-all"] }) + } + + // ── Processes ──────────────────────────────────────────────────────────── + + Process { + id: availabilityProc + onExited: (exitCode) => { root.available = (exitCode === 0) } + } + + Process { + id: keyringProbeProc + stdout: StdioCollector { + id: keyringProbeCollector + onStreamFinished: root.keyringAvailable = (keyringProbeCollector.text.trim() === "yes") + } + } + + Process { id: staleClearProc } + + Process { + id: scanProc + property list buffer: [] + stdout: SplitParser { + onRead: (line) => { if (line.trim().length > 0) scanProc.buffer.push(line.trim()) } + } + onStarted: scanProc.buffer = [] + onExited: { + root.availableVaults = scanProc.buffer + root.vaultExists = root.availableVaults.includes(root.vaultPath) + if (root.vaultExists && root.open) root.refreshEntries() + } + } + + Process { + id: createProc + property string targetPath: "" + stdinEnabled: true + stderr: StdioCollector { id: createErr } + onExited: (exitCode) => { + busy = false + if (exitCode === 0) { + root.vaultPath = createProc.targetPath + root.vaultExists = true + root.availableVaults = [...root.availableVaults, createProc.targetPath] + root.lastError = "" + root.refreshEntries() + } else { + const err = createErr.text.trim() + root.lastError = err.length > 0 ? err.replace("quickshell-keepass: ", "") : Translation.tr("Failed to create vault") + } + } + } + + Process { id: lockProc } + + Timer { + id: autoCloseTimer + interval: 400 + repeat: false + onTriggered: root.close() + } + + Process { + id: listProc + property list buffer: [] + stdinEnabled: true + onRunningChanged: { + if (running && root.password.length > 0) { + write(root.password + "\n") + } + } + stdout: SplitParser { + onRead: (line) => { + if (line && line.trim().length > 0) + listProc.buffer.push(line) + } + } + stderr: StdioCollector { id: listErr } + onExited: (exitCode) => { + busy = false + if (exitCode === 0) { + root.entries = listProc.buffer + + // If root.password is set, it means we just performed a fresh unlock + if (root.password.length > 0) { + root.remainingTime = root.cacheTtl + } + + root.unlocked = true + root.lastError = "" + // Keep the password in memory only when the keyring can't cache + // it, so subsequent copy/reveal still have a source to feed. + if (root.keyringAvailable) root.password = "" + } else { + root.entries = [] + root.unlocked = false + const err = listErr.text.trim() + root.lastError = err.length > 0 ? err.replace("quickshell-keepass: ", "") : Translation.tr("Unlock failed") + } + } + } + + Process { + id: getProc + stdinEnabled: true + onRunningChanged: { + if (running && root.password.length > 0) { + write(root.password + "\n") + } + } + stdout: StdioCollector { + id: passwordCollector + onStreamFinished: { + const pwd = passwordCollector.text.replace(/\n+$/, "") + root.revealedPassword = pwd + root.reveal = true + root.lastError = "" + } + } + stderr: StdioCollector { id: getErr } + onExited: (exitCode) => { + if (exitCode !== 0) { + const err = getErr.text.trim() + root.lastError = err.length > 0 ? err.replace("quickshell-keepass: ", "") : Translation.tr("Failed to read password") + root.unlocked = false + } + } + } + + Timer { + id: cliphistCleanupTimer + interval: 150 + repeat: false + onTriggered: cliphistCleanupProc.exec(["bash", "-c", "cliphist list | head -n 1 | cliphist delete"]) + } + + Process { id: cliphistCleanupProc } + + Process { + id: copyGetProc + stdinEnabled: true + onRunningChanged: { + if (running && root.password.length > 0) { + write(root.password + "\n") + } + } + stdout: StdioCollector { + id: copyCollector + onStreamFinished: { + const value = copyCollector.text.replace(/\n+$/, "") + if (value.length > 0) { + Quickshell.clipboardText = value + root.lastError = "" + cliphistCleanupTimer.restart() + autoCloseTimer.restart() + } + } + } + stderr: StdioCollector { id: copyErr } + onExited: (exitCode) => { + if (exitCode !== 0) { + const err = copyErr.text.trim() + root.lastError = err.length > 0 ? err.replace("quickshell-keepass: ", "") : Translation.tr("Failed to copy password") + root.unlocked = false + } + } + } + + Process { + id: copyUsernameProc + stdinEnabled: true + onRunningChanged: { + if (running && root.password.length > 0) { + write(root.password + "\n") + } + } + stdout: StdioCollector { + id: copyUsernameCollector + onStreamFinished: { + const value = copyUsernameCollector.text.replace(/\n+$/, "") + if (value.length > 0) { + Quickshell.clipboardText = value + root.lastError = "" + autoCloseTimer.restart() + } + } + } + stderr: StdioCollector { id: copyUserErr } + onExited: (exitCode) => { + if (exitCode !== 0) { + const err = copyUserErr.text.trim() + root.lastError = err.length > 0 ? err.replace("quickshell-keepass: ", "") : Translation.tr("Failed to copy username") + root.unlocked = false + } + } + } + + Process { + id: addProc + property string entryPassword: "" + stdinEnabled: true + onRunningChanged: { + if (running) { + const payload = root.password.length > 0 + ? root.password + "\n" + addProc.entryPassword + "\n" + : addProc.entryPassword + "\n" + write(payload) + } + } + stderr: StdioCollector { id: addErr } + onExited: (exitCode) => { + addProc.entryPassword = "" + if (exitCode === 0) { + root.lastError = "" + root.refreshEntries() + } else { + const err = addErr.text.trim() + root.lastError = err.length > 0 ? err.replace("quickshell-keepass: ", "") : Translation.tr("Failed to add entry") + root.unlocked = false + } + } + } + + Process { + id: genProc + stdout: StdioCollector { + id: genCollector + onStreamFinished: root.generatedPassword = genCollector.text.replace(/\n+$/, "") + } + } + + Process { + id: selectionProc + stdout: StdioCollector { + id: selectionCollector + onStreamFinished: root.pendingPassword = selectionCollector.text.replace(/\n+$/, "").trim() + } + onExited: (exitCode) => { + if (exitCode !== 0) root.pendingPassword = "" + root.open = true + root.addMode = true + // Keep the unlocked state when the DB is already open — the keyring + // still holds the password, so dropping it here only flashed the + // unlock tab even though we could save right away. + if (!root.unlocked) root.resetSensitive() + root.refreshEntries() + } + } + + // ── IPC ────────────────────────────────────────────────────────────────── + + IpcHandler { + target: "keepass" + + function toggle(): void { + if (root.open) { + root.close() + } else { + root.openList() + } + } + + function add(): void { + root.openAddWithSelection() + } + } +} diff --git a/services/qmldir b/services/qmldir index eb8b41ad0..89409b42b 100644 --- a/services/qmldir +++ b/services/qmldir @@ -27,6 +27,7 @@ HyprlandData 1.0 HyprlandData.qml singleton Hyprsunset 1.0 Hyprsunset.qml singleton IconThemeService 1.0 IconThemeService.qml singleton Idle 1.0 Idle.qml +singleton KeePass 1.0 KeePass.qml singleton KeyboardIndicators 1.0 KeyboardIndicators.qml singleton MaterialThemeLoader 1.0 MaterialThemeLoader.qml singleton MemoryPressureService 1.0 MemoryPressureService.qml diff --git a/translations/ar_SA.json b/translations/ar_SA.json index ea7e4b5f3..47c907065 100644 --- a/translations/ar_SA.json +++ b/translations/ar_SA.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓، J/K: التنقل • أدخل: لصق", "☕ Break: %1 minutes": "☕ استراحة: %1 دقيقة", "🌿 Long break: %1 minutes": "🌿 استراحة طويلة: %1 دقيقة", - "🔴 Focus: %1 minutes": "🔴 التركيز: %1 دقيقة" -} + "🔴 Focus: %1 minutes": "🔴 التركيز: %1 دقيقة", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "تفاحة نمر نهر حجر سحابة غابة جسر شمعة مطرقة عنكبوت جزيرة قلعة مرآة رعد حديقة قلم زبدة قهوة فلفل شتاء خريف إبرة صاروخ كوكب فضة نحاس كمان صبار فانوس جوزة ظل رخام جزرة وسادة مدفع برميل برسيم حمار قدر شريط سلمون نفق مرساة مطر ثلج ريح شمس قمر نجمة إجاص برتقال فراولة كتاب كرسي طاولة نافذة باب" +} \ No newline at end of file diff --git a/translations/de_DE.json b/translations/de_DE.json index 2c56d7763..1a1798dc6 100644 --- a/translations/de_DE.json +++ b/translations/de_DE.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Navigieren • Eingabetaste: Einfügen", "☕ Break: %1 minutes": "☕ Pause: %1 Minuten", "🌿 Long break: %1 minutes": "🌿 Lange Pause: %1 Minuten", - "🔴 Focus: %1 minutes": "🔴 Fokus: %1 Minuten" -} + "🔴 Focus: %1 minutes": "🔴 Fokus: %1 Minuten", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "Apfel Tiger Fluss Stein Wolke Wald Brücke Kerze Hammer Spinne Insel Schloss Spiegel Donner Garten Bleistift Butter Kaffee Pfeffer Winter Herbst Nadel Rakete Planet Silber Kupfer Geige Kaktus Laterne Walnuss Schatten Marmor Karotte Kissen Kanone Fass Klee Esel Kessel Schleife Lachs Tunnel Anker Regen Schnee Wind Sonne Mond Stern Birne Orange Erdbeere Buch Stuhl Tisch Fenster Tür" +} \ No newline at end of file diff --git a/translations/en_US.json b/translations/en_US.json index 85deb7caa..cd75da3e7 100644 --- a/translations/en_US.json +++ b/translations/en_US.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Navigate • Enter: Paste", "☕ Break: %1 minutes": "☕ Break: %1 minutes", "🌿 Long break: %1 minutes": "🌿 Long break: %1 minutes", - "🔴 Focus: %1 minutes": "🔴 Focus: %1 minutes" -} + "🔴 Focus: %1 minutes": "🔴 Focus: %1 minutes", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "apple tiger river stone cloud forest bridge candle hammer spider island castle mirror thunder garden pencil butter coffee pepper winter autumn needle rocket planet silver copper violin cactus lantern walnut shadow marble carrot pillow cannon barrel clover donkey kettle ribbon salmon tunnel anchor rain snow wind sun moon star pear orange strawberry book chair table window door" +} \ No newline at end of file diff --git a/translations/es_AR.json b/translations/es_AR.json index a19e83902..4aea8c642 100644 --- a/translations/es_AR.json +++ b/translations/es_AR.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Navegar • Ingresar: Pegar", "☕ Break: %1 minutes": "☕ Descanso: %1 minutos", "🌿 Long break: %1 minutes": "🌿 Descanso largo: %1 minutos", - "🔴 Focus: %1 minutes": "🔴 Enfoque: %1 minutos" -} + "🔴 Focus: %1 minutes": "🔴 Enfoque: %1 minutos", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "manzana tigre río piedra nube bosque puente vela martillo araña isla castillo espejo trueno jardín lápiz mantequilla café pimienta invierno otoño aguja cohete planeta plata cobre violín cactus linterna nuez sombra mármol zanahoria almohada cañón barril trébol burro caldero lazada salmón túnel ancla lluvia nieve viento sol luna estrella pera naranja fresa libro silla mesa ventana puerta" +} \ No newline at end of file diff --git a/translations/fr_FR.json b/translations/fr_FR.json index d702dfda3..a93f602ae 100644 --- a/translations/fr_FR.json +++ b/translations/fr_FR.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K : Naviguer • Entrée : Coller", "☕ Break: %1 minutes": "☕ Pause : %1 minutes", "🌿 Long break: %1 minutes": "🌿 Longue pause : %1 minutes", - "🔴 Focus: %1 minutes": "🔴 Mise au point : %1 minutes" -} + "🔴 Focus: %1 minutes": "🔴 Mise au point : %1 minutes", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "pomme tigre rivière pierre nuage forêt pont bougie marteau araignée île château miroir tonnerre jardin crayon beurre café poivre hiver automne aiguille fusée planète argent cuivre violon cactus lanterne noix ombre marbre carotte oreiller canon tonneau trèfle âne chaudron ruban saumon tunnel ancre pluie neige vent soleil lune étoile poire orange fraise livre chaise table fenêtre porte" +} \ No newline at end of file diff --git a/translations/he_HE.json b/translations/he_HE.json index af42eab27..4fc847ab7 100644 --- a/translations/he_HE.json +++ b/translations/he_HE.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: ניווט • הזן: הדבק", "☕ Break: %1 minutes": "☕ הפסקה: %1 דקות", "🌿 Long break: %1 minutes": "🌿 הפסקה ארוכה: %1 דקות", - "🔴 Focus: %1 minutes": "🔴 פוקוס: %1 דקות" -} + "🔴 Focus: %1 minutes": "🔴 פוקוס: %1 דקות", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "תפוח נמר נהר אבן ענן יער גשר נר פטיש עכביש אי טירה מראה רעם גן עיפרון חמאה קפה פלפל חורף סתיו מחט טיל כוכב כסף נחושת כינור קקטוס פנס אגוז צל שיש גזר כרית תותח חבית תלתן חמור קדרה סרט סלמון מנהרה עוגן גשם שלג רוח שמש ירח כוכב אגס תפוז תות ספר כיסא שולחן חלון דלת" +} \ No newline at end of file diff --git a/translations/hi_IN.json b/translations/hi_IN.json index 1a4376cc1..e2922bf39 100644 --- a/translations/hi_IN.json +++ b/translations/hi_IN.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, जे/के: नेविगेट करें • दर्ज करें: चिपकाएँ", "☕ Break: %1 minutes": "☕ ब्रेक: %1 मिनट", "🌿 Long break: %1 minutes": "🌿 लंबा ब्रेक: %1 मिनट", - "🔴 Focus: %1 minutes": "🔴 फोकस: %1 मिनट" -} + "🔴 Focus: %1 minutes": "🔴 फोकस: %1 मिनट", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "सेब बाघ नदी पत्थर बादल जंगल पुल मोमबत्ती हथौड़ा मकड़ी द्वीप किला दर्पण गड़गड़ाहट बगीचा पेंसिल मक्खन कॉफी मिर्च सर्दी शरद सुई रॉकेट ग्रह चांदी तांबा वायलिन कैक्टस लालटेन अखरोट छाया संगमरमर गाजर तकिया तोप बैरल तिपतिया गधा केतली रिबन सैल्मन सुरंग लंगर बारिश बर्फ हवा सूरज चाँद तारा नाशपाती संतरा स्ट्रॉबेरी किताब कुर्सी मेज खिड़की दरवाजा" +} \ No newline at end of file diff --git a/translations/it_IT.json b/translations/it_IT.json index d2c540c05..b6e66f972 100644 --- a/translations/it_IT.json +++ b/translations/it_IT.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Naviga • Invio: Incolla", "☕ Break: %1 minutes": "☕ Pausa breve: %1 minuti", "🌿 Long break: %1 minutes": "🌿 Pausa lunga: %1 minuti", - "🔴 Focus: %1 minutes": "🔴 Focus: %1 minuti" -} + "🔴 Focus: %1 minutes": "🔴 Focus: %1 minuti", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "gatto cane coniglio pipistrello cigno merlo farfalla serpente lucertola tartaruga volpe pane formaggio burro miele pasta pizza uovo salame mozzarella basilico rosmarino cioccolata biscotto torta gelato caffè birra finestra tenda lampada sedia tavolo cuscino pentola padella mestolo forbici albero fiore foglia ramo bosco collina montagna lago roccia scarpa cappello borsa zaino ombrello maglione giacca chiave telefono treno bicicletta pioggia neve vento sole luna stella mela pera arancia limone fragola" +} \ No newline at end of file diff --git a/translations/ja_JP.json b/translations/ja_JP.json index 934bc12b7..470a34d75 100644 --- a/translations/ja_JP.json +++ b/translations/ja_JP.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓、J/K: 移動 • Enter: 貼り付け", "☕ Break: %1 minutes": "☕ 休憩: %1分", "🌿 Long break: %1 minutes": "🌿 長い休憩: %1分", - "🔴 Focus: %1 minutes": "🔴 集中: %1分" -} + "🔴 Focus: %1 minutes": "🔴 集中: %1分", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "りんご とら かわ いし くも もり はし ろうそく かなづち くも しま しろ かがみ かみなり にわ えんぴつ バター コーヒー こしょう ふゆ あき はり ロケット わくせい ぎんいろ どう バイオリン サボテン ランタン くるみ かげ だいりせき にんじん まくら たいほう たる クローバー ろば やかん リボン サーモン トンネル いかり あめ ゆき かぜ たいよう つき ほし なし オレンジ いちご ほん いす つくえ まど ドア" +} \ No newline at end of file diff --git a/translations/ko_KR.json b/translations/ko_KR.json index 40d0eaedc..da60041d9 100644 --- a/translations/ko_KR.json +++ b/translations/ko_KR.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: 탐색 • Enter: 붙여넣기", "☕ Break: %1 minutes": "fo 휴식 시간: %1분", "🌿 Long break: %1 minutes": "🙂 긴 휴식 시간: %1분", - "🔴 Focus: %1 minutes": "🔴 초점: %1분" -} + "🔴 Focus: %1 minutes": "🔴 초점: %1분", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "사과 호랑이 강물 돌멩이 구름 숲속 다리 촛불 망치 거미 섬나라 성벽 거울 천둥 정원 연필 버터 커피 후추 겨울 가을 바늘 로켓 행성 은색 구리 바이올린 선인장 랜턴 호두 그림자 대리석 당근 베개 대포 통나무 클로버 당나귀 주전자 리본 연어 터널 닻줄 빗물 눈송이 바람 태양 달빛 별빛 배나무 오렌지 딸기 책상 의자 테이블 창문 문짝" +} \ No newline at end of file diff --git a/translations/pt_BR.json b/translations/pt_BR.json index a10619c09..0aea5d8a2 100644 --- a/translations/pt_BR.json +++ b/translations/pt_BR.json @@ -3761,5 +3761,20 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Navegar • Enter: Colar", "☕ Break: %1 minutes": "☕ Intervalo: %1 minutos", "🌿 Long break: %1 minutes": "🌿 Pausa longa: %1 minutos", - "🔴 Focus: %1 minutes": "🔴 Foco: %1 minutos" -} + "🔴 Focus: %1 minutes": "🔴 Foco: %1 minutos", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Hide": "Hide", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "maçã tigre rio pedra nuvem floresta ponte vela martelo aranha ilha castelo espelho trovão jardim lápis manteiga café pimenta inverno outono agulha foguete planeta prata cobre violino cacto lanterna noz sombra mármore cenoura travesseiro canhão barril trevo burro caldeirão laço salmão túnel âncora chuva neve vento sol lua estrela pera laranja morango livro cadeira mesa janela porta" +} \ No newline at end of file diff --git a/translations/ru_RU.json b/translations/ru_RU.json index 44072eb05..95e4de4a5 100644 --- a/translations/ru_RU.json +++ b/translations/ru_RU.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Навигация • Ввод: Вставить", "☕ Break: %1 minutes": "☕ Перерыв: %1 минут.", "🌿 Long break: %1 minutes": "🌿 Длинный перерыв: %1 минут.", - "🔴 Focus: %1 minutes": "🔴 Фокус: %1 минут." -} + "🔴 Focus: %1 minutes": "🔴 Фокус: %1 минут.", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "яблоко тигр река камень облако лес мост свеча молоток паук остров замок зеркало гром сад карандаш масло кофе перец зима осень игла ракета планета серебро медь скрипка кактус фонарь орех тень мрамор морковь подушка пушка бочка клевер осёл котёл лента лосось тоннель якорь дождь снег ветер солнце луна звезда груша апельсин клубника книга стул стол окно дверь" +} \ No newline at end of file diff --git a/translations/uk_UA.json b/translations/uk_UA.json index 2538891e5..a5e2816d6 100644 --- a/translations/uk_UA.json +++ b/translations/uk_UA.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Навігація • Enter: Вставити", "☕ Break: %1 minutes": "☕ Перерва: %1 хвилина", "🌿 Long break: %1 minutes": "🌿 Тривала перерва: %1 хвилина", - "🔴 Focus: %1 minutes": "🔴 Зосередженість: %1 хвилина" -} + "🔴 Focus: %1 minutes": "🔴 Зосередженість: %1 хвилина", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "яблуко тигр річка камінь хмара ліс міст свічка молоток павук острів замок дзеркало грім сад олівець масло кава перець зима осінь голка ракета планета срібло мідь скрипка кактус ліхтар горіх тінь мармур морква подушка гармата бочка конюшина осел казан стрічка лосось тунель якір дощ сніг вітер сонце місяць зоря груша апельсин полуниця книга стілець стіл вікно двері" +} \ No newline at end of file diff --git a/translations/vi_VN.json b/translations/vi_VN.json index e45ca05d8..8cadc1058 100644 --- a/translations/vi_VN.json +++ b/translations/vi_VN.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓, J/K: Điều hướng • Enter: Dán", "☕ Break: %1 minutes": "☕ Nghỉ giải lao: %1 phút", "🌿 Long break: %1 minutes": "🌿 Nghỉ giải lao dài: %1 phút", - "🔴 Focus: %1 minutes": "🔴 Tập trung: %1 phút" -} + "🔴 Focus: %1 minutes": "🔴 Tập trung: %1 phút", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "táo hổ sông đá mây rừng cầu nến búa nhện đảo lâu gương sấm vườn bút bơ cà phê tiêu đông thu kim tên lửa hành tinh bạc đồng violin xương rồng đèn lồng hạt óc chó bóng hoa đài cà rốt gối pháo thùng phi cỏ ba lá con lừa ấm ruy băng cá hồi đường hầm mỏ neo mưa tuyết gió mặt trời mặt trăng ngôi sao lê cam dâu sách ghế bàn cửa sổ cửa" +} \ No newline at end of file diff --git a/translations/zh_CN.json b/translations/zh_CN.json index 6f47ffe0f..c0cf43002 100644 --- a/translations/zh_CN.json +++ b/translations/zh_CN.json @@ -3761,5 +3761,19 @@ "↑/↓, J/K: Navigate • Enter: Paste": "↑/↓、J/K:导航 • 输入:粘贴", "☕ Break: %1 minutes": "☕ 休息:%1 分钟", "🌿 Long break: %1 minutes": "🌿 长休息:%1 分钟", - "🔴 Focus: %1 minutes": "🔴 专注:%1 分钟" -} + "🔴 Focus: %1 minutes": "🔴 专注:%1 分钟", + "KeePass": "KeePass", + "KeePass - Save": "KeePass - Save", + "Vault password": "Vault password", + "Stay unlocked:": "Stay unlocked:", + "Unlock": "Unlock", + "Search entries": "Search entries", + "Copy Password": "Copy Password", + "Copy Username": "Copy Username", + "Entry name (e.g. Email/GitHub)": "Entry name (e.g. Email/GitHub)", + "Username (optional)": "Username (optional)", + "URL (optional)": "URL (optional)", + "Generate": "Generate", + "Len:": "Len:", + "keepass_wordlist": "苹果 老虎 河流 石头 云朵 森林 桥梁 蜡烛 锤子 蜘蛛 岛屿 城堡 镜子 雷声 花园 铅笔 黄油 咖啡 胡椒 冬天 秋天 针头 火箭 星球 白银 铜块 小提琴 仙人掌 灯笼 核桃 阴影 大理石 胡萝卜 枕头 大炮 木桶 三叶草 驴子 水壶 缎带 鲑鱼 隧道 锚具 雨水 白雪 微风 太阳 月亮 星星 梨子 橙子 草莓 书本 椅子 桌子 窗户 门口" +} \ No newline at end of file From 2d67a014a5409a91495ca4f300d8d7be5c5fbcb6 Mon Sep 17 00:00:00 2001 From: rederyk Date: Sun, 17 May 2026 16:12:25 +0200 Subject: [PATCH 2/3] feat(keepass): add settings UI in ServicesConfig Adds a KeePass section to the Services settings page allowing users to configure keepass.vaultDir and the cache TTL from the shell UI. --- modules/settings/ServicesConfig.qml | 39 +++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/modules/settings/ServicesConfig.qml b/modules/settings/ServicesConfig.qml index c66b8f2a3..a478ebcab 100644 --- a/modules/settings/ServicesConfig.qml +++ b/modules/settings/ServicesConfig.qml @@ -2016,4 +2016,43 @@ ContentPage { } } + SettingsCardSection { + expanded: false + icon: "key" + title: Translation.tr("KeePass") + + SettingsGroup { + StyledText { + Layout.fillWidth: true + text: Translation.tr("Integrate with KeePassXC-CLI to manage passwords directly from the shell.") + color: Appearance.colors.colOnSurfaceVariant + font.pixelSize: Appearance.font.pixelSize.small + wrapMode: Text.WordWrap + } + + MaterialTextArea { + Layout.fillWidth: true + placeholderText: Translation.tr("Vault directory (where your .kdbx files are)") + text: Config.options?.keepass?.vaultDir ?? "" + wrapMode: TextEdit.Wrap + onTextChanged: Config.setNestedValue("keepass.vaultDir", text) + StyledToolTip { + text: Translation.tr("The directory containing your KeePass databases. If empty, the shell will look in common locations.") + } + } + + ConfigSpinBox { + icon: "timer" + text: Translation.tr("Stay unlocked") + ` (${Math.floor(value/60)}m ${value%60}s)` + value: Config.options?.keepass?.cacheTtl ?? 300 + from: 60 + to: 14400 + stepSize: 60 + onValueChanged: Config.setNestedValue("keepass.cacheTtl", value) + StyledToolTip { + text: Translation.tr("How long the vault stays unlocked after entering the password before it auto-locks and clears the cached password.") + } + } + } + } } From a6b91b29e56b87fb16a81a86e3c38d81fd4f29aa Mon Sep 17 00:00:00 2001 From: rederyk Date: Sun, 17 May 2026 16:12:28 +0200 Subject: [PATCH 3/3] docs(keepass): add KEEPASS.md documentation Documents the panel features (vault management, entry search, password operations, generation), all IPC commands, configuration options (keepass.vaultDir, keepass.cacheTtl), and the quickshell-keepass script interface. --- docs/KEEPASS.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/KEEPASS.md diff --git a/docs/KEEPASS.md b/docs/KEEPASS.md new file mode 100644 index 000000000..e501463a9 --- /dev/null +++ b/docs/KEEPASS.md @@ -0,0 +1,92 @@ +# KeePass Integration + +The KeePass integration in iNiR provides a secure, fast, and visually integrated way to manage your passwords directly from the shell. + +## Architecture + +The system consists of three layers: + +1. **Backend Script (`scripts/quickshell-keepass`)**: A robust wrapper around `keepassxc-cli`. It handles the core logic, error reporting, and secure password caching using the system keyring. +2. **Service (`services/KeePass.qml`)**: A background service that manages the database state (unlocked/locked), handles automated background locking via a timer, and exposes IPC targets. +3. **UI Panel (`modules/keepass/KeepassPanel.qml`)**: A material-style overlay following the "SnowArch" aesthetic, providing search, entry management, and timer controls. + +## Features + +### Secure Caching +Unlike standard scripts that might save passwords in plain text or volatile files, iNiR uses the **Secret Service API** (`secret-tool`). +- The vault password is stored in your session keyring (Gnome Keyring or KWallet). +- Each cache item is **scoped to its vault path**, so vaults with different master passwords never feed each other a stale password. +- The cache is automatically cleared when the timer expires, when manually locked, and on shell start (the shell always starts locked, so no master password lingers after a crash or a previous session). +- **No-keyring fallback**: if no Secret Service is reachable, the password is kept in memory for the session instead, so copy/reveal keep working (probed at startup via `quickshell-keepass check-keyring`). + +### Smart Timer System +The integration features a persistent background timer: +- **Interactive Slider**: Set the unlock duration (from 1 minute to 4 hours) before unlocking. +- **Title Bar Badge**: A live countdown timer (`MM:SS`) is visible in the title bar when the database is open. +- **Visual Progress**: A subtle progress bar inside the badge shows the remaining time relative to the initial setting. +- **Critical Alerts**: The timer turns **red** when less than 30 seconds remain. +- **Quick Reset**: Click the timer badge in the title bar to instantly reset the time to the maximum duration without re-entering the password. + +### Integrated UI +- **Auto-Focus**: Opening the panel automatically focuses the password field or the search field. +- **Pill Style**: All UI elements (selections, buttons, list items) use the "pill" shape and colors harmonized with your system theme (`colPrimary`). +- **High Contrast**: Text automatically inverts (`colOnPrimary`) when inside a selected "pill" for maximum readability. +- **Keyboard Friendly**: Fully navigable via keyboard. `Tab` cycles only between inputs and list; `Enter` selects or saves. + +## Configuration + +Configure the vault location in `~/.config/illogical-impulse/config.json`: + +```json +"keepass": { + "vaultDir": "/path/to/your/vaults", + "cacheTtl": 300 +} +``` + +- `keepass.vaultDir`: directory containing your `.kdbx` vaults (defaults to `~/.local/share/keepassqs`). +- `keepass.cacheTtl`: how long (in seconds) the vault stays unlocked before it auto-locks and clears the cached password (defaults to `300`). Settable from the in-panel slider or the **Settings → Services → KeePass** section; both persist here. + +Quickshell hot-reloads the config, so the panel picks up changes on the next toggle. The picker lists all `.kdbx` files found in that directory, and new vaults can be created from the UI. The word-based generator uses the wordlist from the active UI locale (via `Translation.tr("keepass_wordlist")`). + +## Usage + +### Commands +- `inir keepass toggle`: Opens or closes the KeePass panel. +- `inir keepass add`: Opens the panel in "Add Entry" mode, automatically pasting the primary selection into the password field. + +### Keyboard Shortcuts + +Default bindings (from `scripts/lib/ipc-registry.sh`): +- `Super+P`: toggle the panel +- `Super+Ctrl+P`: open the panel in "Add Entry" mode with the primary selection pre-filled + +Inside the panel: +- `Enter` on the password field: unlock the vault +- Start typing (in entries tab): focuses the search field +- `Down` / `Up`: navigate the entry list or vault picker list; in the entry list the highlighted entry is auto-selected (its detail card opens as you move); `Up` at the top of the entry list returns focus to search +- `Enter` on a highlighted entry: copy its password to the clipboard (no double-press — the entry is already selected) +- `Enter` on a highlighted vault (picker tab): open the unlock screen for it +- `Left` / `Right`: cycle through the three tabs — picker → entries → add — when the vault is unlocked +- `Tab`: enter the form fields on the create-vault or add-entry tabs (focus starts outside the fields so the arrows remain free for tab cycling) +- **Hold `Alt`**: reveal the selected entry's password while held — releasing hides it again (avoids moving keyboard focus away from the list) +- `Escape`: close the entry detail, or close the panel if no entry is open + +## Security Model +1. **Locking**: The database is locked and the keyring cache is cleared immediately when the timer reaches zero or the "Lock" button is pressed. The cache is also wiped on shell start, so a cached password never outlives the session that created it. +2. **Transparency**: Real error messages from `keepassxc-cli` (e.g., "Database locked by another process") are displayed directly in the UI for better diagnostics. +3. **Clipboard**: Passwords copied to the clipboard are automatically cleared from the `cliphist` history after a short delay (if `cliphist` is active). + +## Troubleshooting + +Set `KP_DEBUG=1` to trace every keyring decision (cache hit/miss, store, clear, stdin fallback) so you can confirm operations go through the keyring with no silent fallbacks. The trace is off by default and has zero overhead when unset. + +```sh +# Enable for the running shell, then watch the trace +systemctl --user set-environment KP_DEBUG=1 && inir restart +tail -F "${XDG_RUNTIME_DIR:-/tmp}/quickshell-keepass-debug.log" +# Disable again +systemctl --user unset-environment KP_DEBUG && inir restart +``` + +Override the log location with `KP_DEBUG_LOG=/path/to/file`. The trace never records secret values, only which code path ran.