Files
asepharyana 1cdb82a76f Sync config from arch
- hypr/apps.lua
- hypr/autostart.lua
- hypr/envs.lua
- hypr/hyprland.lua
- hypr/hyprsunset.conf
- hypr/input.lua
- hypr/looknfeel.lua
- hypr/omasettings.lua
- hypr/xdph.conf
- omarchy/branding/about.txt
- omarchy/branding/screensaver.txt
- omarchy/extensions/omarchy-menu.jsonc
- omarchy/hooks/battery-low.d/play-warning-sound.sample
- omarchy/hooks/font-set.d/show-font-notification.sample
- omarchy/hooks/post-boot.d/weather.sample
- omarchy/hooks/post-update.d/install-voxtype.hook
- omarchy/hooks/post-update.d/setup-agent.hook
- omarchy/hooks/post-update.d/setup-fingerprint.hook
- omarchy/hooks/post-update.d/show-update-notification.sample
- omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample
- omarchy/hooks/theme-set.d/show-theme-notification.sample
- omarchy/shell.json
- omarchy/shell.toml
- omarchy/theme.name
- omarchy/themes/azure-glow/README.md
- omarchy/themes/azure-glow/alacritty.toml
- omarchy/themes/azure-glow/btop.theme
- omarchy/themes/azure-glow/hyprland.conf
- omarchy/themes/azure-glow/hyprlock.conf
- omarchy/themes/azure-glow/icons.theme
- … 269 more
2026-09-23 15:19:12 +07:00

11755 lines
450 KiB
QML

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import qs.Commons
import qs.Ui
import "BitwardenModel.js" as Model
Panel {
id: root
moduleName: "io.github.elevate08.qs-bitwarden-cli"
ipcTarget: "io.github.elevate08.qs-bitwarden-cli"
manageIpc: false
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
// Configuration settings from shell.json. The numbers go through the schema
// on the way in as well as on the way out -- nothing validates shell.json,
// and a bad minute count does not fail loudly, it just stops the vault ever
// locking itself. See intSetting() in BitwardenModel.js.
readonly property int autoLockMinutes: Model.intSetting("autoLockMinutes", setting("autoLockMinutes"))
readonly property int clearClipboardSec: Model.intSetting("clearClipboardSec", setting("clearClipboardSec"))
readonly property bool lockOnScreenLock: Model.boolSetting("lockOnScreenLock", setting("lockOnScreenLock", true))
readonly property bool lockOnSuspend: Model.boolSetting("lockOnSuspend", setting("lockOnSuspend", true))
readonly property bool rememberSession: Model.boolSetting("rememberSession", setting("rememberSession", true))
readonly property int autoCopyTotpSec: Model.intSetting("autoCopyTotpSec", setting("autoCopyTotpSec"))
readonly property bool closeOnCopy: Model.boolSetting("closeOnCopy", setting("closeOnCopy", true))
readonly property bool colorizeIcon: Model.boolSetting("colorizeIcon", setting("colorizeIcon", false))
readonly property bool suggestOnOpen: Model.boolSetting("suggestOnOpen", setting("suggestOnOpen", true))
readonly property bool fingerprintUnlock: Model.boolSetting("fingerprintUnlock", setting("fingerprintUnlock", false))
readonly property bool pinUnlock: Model.boolSetting("pinUnlock", setting("pinUnlock", false))
// The SSH agent is opt-in. Nothing starts a helper, creates a socket, or
// touches a FIFO while this is false.
readonly property bool sshAgentEnabled: Model.boolSetting("sshAgentEnabled", setting("sshAgentEnabled", false))
readonly property bool sshAgentUnlockOnDemand: Model.boolSetting("sshAgentUnlockOnDemand", setting("sshAgentUnlockOnDemand", false))
readonly property bool sshAgentApprovalPopup: Model.boolSetting("sshAgentApprovalPopup", setting("sshAgentApprovalPopup", true))
readonly property int sshAgentApprovalWindowSec: Model.intSetting("sshAgentApprovalWindowSec", setting("sshAgentApprovalWindowSec"))
// The SSH sections' own section header. PanelSectionHeader comes from the
// Omarchy shell, and its defaults are the global theme's -- `Color.foreground`
// and `Style.font.family` -- while everything around it here follows the bar's
// own foreground and font family. Stating them once keeps the headers matching
// the captions beneath them, and keeps `textFormat` explicit, which this
// panel requires of every text element whether or not its text is constant
// today.
component SshSectionHeader: PanelSectionHeader {
textFormat: Text.PlainText
foreground: root.fg
fontFamily: root.fontFamily
}
component SshCaption: Text {
textFormat: Text.PlainText
width: parent ? parent.width : 0
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// One of the three vault filters at the foot of the list, collapsed to its
// current value. Declared once so the three cannot drift apart and start
// reading as different kinds of control.
//
// The button names its filter as well as showing its value. The glyphs alone
// do not carry it: the three sit together reading "All", "All", "All" for as
// long as nothing is filtered, which is exactly when the value says least and
// the name says most. So the name stays, and the row is allowed to take a
// second line on the rarer occasions all three are set to something long.
//
// The value is still clipped. `Ui.Button` has no elide, so a folder named
// after a whole client engagement would make one button wider than the whole
// panel -- and a row that wraps can move a button to the next line but can
// never make one narrower than the panel it is in.
component VaultFilterButton: Button {
required property string group
required property string glyph
required property string name
required property string value
required property string shortcut
// Clipped first, then neutralized: plainLabel may return a <span>, and
// slicing that would cut the tag in half.
text: Model.plainLabel(name + ": " + Model.clipLabel(value, 20))
iconText: root.openFilterGroup === group ? "󰅀" : glyph
selected: root.openFilterGroup === group
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.caption
horizontalPadding: Style.space(10)
// The full value, unclipped, is still one hover away -- and the tooltip is
// drawn by the kit's own auto-detecting Text, so it is neutralized too.
tooltipText: Model.plainLabel(name + " filter (" + shortcut + "): " + value)
onClicked: root.toggleFilterGroup(group)
}
// State
// status: "checking" | "unauthenticated" | "locked" | "unlocked"
property string status: "checking"
property string userEmail: ""
property string session: ""
property string masterPassword: ""
// Login form state
property string loginMethod: "email" // "email" | "apikey"
property string loginEmail: ""
property string loginPassword: ""
property string login2faCode: ""
property string loginServerRegion: "us" // "us" | "eu" | "custom"
property string loginServerUrl: ""
property string loginClientId: ""
property string loginClientSecret: ""
property bool show2faField: false
// Whether the login attempt now running carries --code. It is the only way
// to tell a rejected two-step code from a new-device-verification challenge;
// see loginNeedsDeviceVerification() in BitwardenModel.js.
property bool loginAttemptHadCode: false
// Set once Bitwarden has asked to verify this device with an emailed OTP.
// bw can only answer that interactively, so the panel stops asking for a
// code it cannot use and points at the terminal login instead.
property bool loginDeviceVerification: false
// Which two-step method this login tells bw to use, or -1 for "let bw
// decide", which is right whenever the account has exactly one. See
// TWO_FACTOR_METHODS in BitwardenModel.js.
property int login2faMethod: rememberedTwoFactorMethod
// Whether that method came from the user picking it in this login rather
// than from the remembered setting. A remembered method can be stale -- it
// is not scoped to an account -- so an unconfirmed one is dropped and
// retried without, where a confirmed one is reported as not configured.
property bool login2faMethodConfirmed: false
property bool show2faMethodPicker: false
// New-device verification collects its code in its own stage, because it is
// answered on a different path from a two-step code and must not be mistaken
// for one. See deviceVerificationLoginCommand() in BitwardenModel.js.
property string loginDeviceCode: ""
property bool showDeviceCodeField: false
// Set while the one login that runs with bw's prompts enabled is in flight,
// so both its environment and its result are read differently.
property bool deviceVerificationAttempt: false
property bool deviceVerificationPending: false
// When the login reached a stage that is waiting on a second factor, as
// epoch ms, or 0 if it is not. A closed panel keeps that login alive for
// SECOND_FACTOR_WINDOW_MS, because an emailed code cannot be read without
// leaving the panel. See secondFactorWindowOpen() in BitwardenModel.js.
property double secondFactorStartedAt: 0
// Whether this login has already spent its one automatic retry at handing
// the password to bw. See onAuthPasswordWriterExited().
property bool loginPasswordRetryUsed: false
// The email login is four stages deep now: credentials, the method question
// when bw asks it, the two-step code, and new-device verification. Only one
// is ever on screen.
readonly property bool loginCredentialsStage:
!show2faField && !show2faMethodPicker && !showDeviceCodeField
readonly property string login2faMethodLabel: Model.twoFactorMethodLabel(login2faMethod)
// The method the last attempt actually sent, so its answer can be read
// against it.
property int loginAttemptMethod: -1
// Keyed by login address, so two vaults on one machine each keep their own
// answer. Tracks loginEmail as it is typed, which is what makes the method
// apply the moment the address is complete.
readonly property var twoFactorMethodStore: setting("twoFactorMethods", null)
readonly property int rememberedTwoFactorMethod:
Model.rememberedTwoFactorMethodFor(twoFactorMethodStore, loginEmail)
// When the panel last launched a terminal login, as epoch ms, or 0 if it
// never did. A session key left in the runtime directory is only adopted in
// the minutes after this; see sessionHandoffReadCommand().
property double terminalLoginStartedAt: 0
// Screens: "main" | "detail" | "edit" | "locked" | "login" | "settings" | "setup"
property string currentScreen: "main"
property string screenBeforeSettings: "main"
// Dependency / setup state
property var dependencies: ({ items: [], hasOmarchy: true })
property bool depsChecked: false
property bool setupDismissed: false
property string listReadMode: "sanitized"
property var sshCapability: Model.defaultSshCapability()
// True while the panel should be showing setup rather than probing `bw`.
// See setupGateActive() in BitwardenModel.js for why the gate exists.
readonly property bool setupGated: Model.setupGateActive(dependencies, depsChecked, setupDismissed)
// Whether the first `bw status` has been started. The probe waits behind the
// dependency check on a fresh install, so something has to remember that it
// still owes the vault a look once the tools arrive.
property bool statusProbeStarted: false
// Set the moment a required tool is seen missing, cleared once the probe
// that follows the install has run. It is what turns "the install finished
// in a terminal we do not own" into a panel that moves on by itself.
property bool setupWasGated: false
property string settingsFlash: ""
property int settingsIndex: 0
readonly property var settingsEntries: Model.visibleSettings(dependencies, depsChecked)
// Vault data
property var items: []
// `bw list items` costs seconds on a large vault, so a reopen reuses what is
// already in memory until it goes stale. Any mutation reloads unconditionally.
property double itemsLoadedAt: 0
property double orgsLoadedAt: 0
property double foldersLoadedAt: 0
readonly property int itemsFreshMs: 60000
// Organizations and folders outlive an item refresh many times over.
readonly property int metaFreshMs: 600000
property var filteredItems: []
property var organizations: []
property string selectedOrg: "all" // "all" | "personal" | orgId
property var folders: []
property string selectedFolder: "all" // "all" | "none" | folderId
// Which bottom filter group is open: "" | "folders" | "organizations" | "types".
// Only one at a time, so the panel grows by one list at most.
property string openFilterGroup: ""
property int filterOptionIndex: 0
readonly property int filterRowHeight: Style.space(30)
readonly property int filterVisibleRows: 5
readonly property var currentFilterOptions: openFilterGroup === "" ? [] : filterOptions(openFilterGroup)
readonly property int currentFilterVisibleRows: openFilterGroup === "types" ? currentFilterOptions.length : filterVisibleRows
// The drawer's own height. The panel adds this to its cap so the window
// opens downward like a drawer instead of squeezing the item list.
readonly property int filterDrawerHeight: openFilterGroup === ""
? 0
: Style.space(30) + Math.min(currentFilterVisibleRows, currentFilterOptions.length) * filterRowHeight + Style.space(8)
property string formFolderId: ""
property string newFolderName: ""
// Which picker in the item form is expanded: "" | "folder" | "organization"
property string formPicker: ""
property var formCollections: []
property var formCollectionIds: []
property bool formCollectionsLoading: false
property bool creatingFolder: false
property string searchQuery: ""
property string selectedCategory: "all"
property int selectedIndex: 0
// Selected item detail
property var detailItem: null
property string detailPassword: ""
// Which sensitive fields on the open item are currently shown, by field key.
//
// One flag used to serve all of them, which was invisible while a login had
// exactly one secret to hide. A card has two and an identity three, and
// revealing a card number also uncovered its security code -- and, on an
// identity, the social security, passport and licence numbers at once. The
// eye on each field now speaks only for that field.
property var revealedFields: ({})
function isFieldRevealed(key) { return Boolean(revealedFields[key]) }
function toggleFieldReveal(key) {
var next = {}
for (var k in revealedFields) next[k] = revealedFields[k]
if (next[key]) delete next[key]
else next[key] = true
revealedFields = next
}
// What `v` reaches: the one secret the open item is mostly about. A card has
// a number, a login has a password. An identity has three identifiers and no
// principal one, so `v` leaves it alone rather than picking arbitrarily --
// each field carries its own eye.
readonly property string primaryRevealKey:
detailIsCard ? "cardNumber" : (detailIsLoginLike ? "password" : "")
// Which detail blocks the open item is entitled to. The login fields --
// username, password, TOTP, website -- used to be gated on "not an SSH
// key", which was the same question while logins and notes were the only
// other types. A card answers "not an SSH key" too, and would have drawn
// an empty password row under its number.
readonly property int detailTypeCode: detailItem ? Number(detailItem.typeCode || 1) : 1
readonly property bool detailIsLoginLike: detailTypeCode === 1 || detailTypeCode === 2
readonly property bool detailIsCard: detailTypeCode === 3
readonly property bool detailIsIdentity: detailTypeCode === 4
readonly property var detailCard: detailItem ? (detailItem.card || null) : null
readonly property var detailIdentity: detailItem ? (detailItem.identity || null) : null
// Expiry reads as one value, so it is composed once here rather than in the
// binding that draws it. A card with only one half filled in shows that
// half rather than a stray slash.
readonly property string detailCardExpiry: {
if (!detailCard) return ""
var m = String(detailCard.expMonth || "").trim()
var y = String(detailCard.expYear || "").trim()
if (m && y) return m + " / " + y
return m || y
}
readonly property string detailIdentityName: detailIdentity ? Model.identityFullName(detailIdentity) : ""
// The postal parts, in the order an envelope wants them, with the empty
// lines left out instead of drawn as blanks.
readonly property string detailIdentityAddress: {
if (!detailIdentity) return ""
var street = [detailIdentity.address1, detailIdentity.address2, detailIdentity.address3]
.map(function(part) { return String(part || "").trim() })
.filter(function(part) { return part !== "" })
var locality = [detailIdentity.city, detailIdentity.state, detailIdentity.postalCode]
.map(function(part) { return String(part || "").trim() })
.filter(function(part) { return part !== "" })
.join(" ")
var country = String(detailIdentity.country || "").trim()
return street.concat(locality ? [locality] : []).concat(country ? [country] : []).join("\n")
}
property string liveTotp: ""
property int totpSecRemaining: 30
property string totpRequestItemId: ""
property string totpQueuedItemId: ""
property int totpQueuedEpoch: -1
property bool totpRestartPending: false
property string totpCopyItemId: ""
property string passwordCopyItemId: ""
// Attachment downloads. One `bw get attachment` runs at a time and the rest
// wait in the queue, so "Save all" on an item with six files does not fire
// six CLI bootstraps at once. `attachmentSaved` maps an attachment id to the
// path it landed on, which is what turns the row's Download button into Open
// and Show in folder; it is cleared whenever a different item is opened.
property var attachmentQueue: []
property string attachmentBusyId: ""
property var attachmentSaved: ({})
// Follow-up TOTP sequential copy state (Enter -> Password -> Enter -> TOTP)
property var totpFollowupItem: null
property string totpFollowupCode: ""
property bool totpFollowupActive: false
// The save currently in flight, or null. Holds what the list showed before
// it, and the form that produced it, so a failure can put both back.
property var pendingSave: null
// The delete currently in flight, or null. Holds the row it removed so a
// refusal can put it back.
property var pendingDelete: null
// A save that came back refused. The list has been restored to what the
// vault actually holds; this is what the user typed, kept so it can be
// reopened rather than retyped.
property var failedSave: null
// Add / Edit Form State
property bool formIsEditing: false
property string formItemId: ""
property int formTypeCode: 1 // 1: Login, 2: Secure Note
property string formName: ""
property string formUsername: ""
property string formPassword: ""
property string formTotp: ""
property string formUri: ""
property string formNotes: ""
property bool formFavorite: false
property string formOrgId: ""
property bool formPasswordRevealed: false
property bool showDeleteConfirm: false
// Card and identity boxes. Flat strings rather than one object per type,
// because that is what every other field on this form is and what the
// TextField two-way binding above expects; formTypeFields() gathers them
// back into the shape the payload builders want.
property string formCardholderName: ""
property string formCardBrand: ""
property string formCardNumber: ""
property string formCardExpMonth: ""
property string formCardExpYear: ""
property string formCardCode: ""
property string formIdTitle: ""
property string formIdFirstName: ""
property string formIdMiddleName: ""
property string formIdLastName: ""
property string formIdUsername: ""
property string formIdCompany: ""
property string formIdEmail: ""
property string formIdPhone: ""
property string formIdSsn: ""
property string formIdPassport: ""
property string formIdLicense: ""
property string formIdAddress1: ""
property string formIdAddress2: ""
property string formIdAddress3: ""
property string formIdCity: ""
property string formIdState: ""
property string formIdPostalCode: ""
property string formIdCountry: ""
// When the current auto-lock window started, in wall-clock terms, so a
// suspend cannot hide from the countdown. See the autoLockWatchdog Timer.
property double autoLockArmedAt: 0
// The vault generation. Moves on whenever the vault changes hands -- locked,
// logged out of, unlocked again -- and every `bw` reader records the one it
// started under, so an answer from a vault that is no longer open can be
// recognised as such when it arrives. See vaultReadIsStale().
property int vaultEpoch: 0
property var readEpochs: ({})
// Processes whose collectors still have to be emptied after a lock. Anything
// that was running at the time stays here until it finishes. See
// scrubSecretBuffers().
property var scrubPending: []
// Status & indicators
property bool isLoading: false
property bool isUnlocking: false
property bool isSyncing: false
property bool metadataLoadPending: false
property bool metadataForceRefresh: false
property bool statusRefreshAfterItems: false
property bool statusCheckAuthoritative: true
// Whether this unlocked session has already tried to repair an unsynced
// vault. See the lastSync check in onStatusFinished().
property bool initialSyncAttempted: false
property bool syncReloadPending: false
property string errorMessage: ""
property string flashMessage: ""
property bool cursorActive: false
// Fingerprint unlock state.
// PAM only proves presence, so a verified finger is used as the gate on
// reading the master password back out of the login keyring.
property bool fingerprintAvailable: false // PAM stack + reader + enrolled finger
property bool fingerprintStored: false // master password present in keyring
property bool fingerprintScanning: false
property bool fingerprintAuthorized: false // a live PAM success may consume one keyring lookup
property string fingerprintMessage: ""
property string pendingUnlockPassword: "" // held only until the unlock lands
// Authentication processes are started before submission and wait on a
// private FIFO. These flags distinguish that harmless waiting state from an
// attempt whose password has actually been delivered.
property bool unlockSubmitted: false
property bool loginSubmitted: false
property bool loginSubmitAfterPrewarmStop: false
property bool loginPrepareAfterPrewarmStop: false
property string loginPrewarmSignature: ""
property string authPasswordWriteTarget: ""
property string authPasswordWriteValue: ""
// The value the keyring store process reads. Set from whichever path is
// storing: the explicit setup form, or the automatic refresh after unlock.
property string masterToStore: ""
// Item JSON on its way to `bw encode`. Held here so the create/edit processes
// can pass it in the environment instead of on the command line.
property string itemPayloadJson: ""
property bool fpSetupActive: false
property string fpSetupMaster: ""
property string fpError: ""
property bool fpBusy: false
// Which credential source drove the in-flight unlock, so a stale stored
// secret can be discarded rather than retried forever. "" | "fingerprint" | "pin"
property string pendingUnlockFrom: ""
// Send state
property var sends: []
property bool sendsLoading: false
property string sendMode: "list" // "list" | "create"
property string sendPayloadJson: ""
property bool sendBusy: false
property string sendError: ""
property string sendFormName: ""
property string sendFormText: ""
property bool sendFormHidden: false
property int sendFormDays: 7
property int sendFormMaxAccess: 0
property string sendFormPassword: ""
property int sendIndex: 0
// Generator state (session-scoped, mirroring the browser extension's options)
property var genOpts: Model.generatorDefaults()
property string genValue: ""
property bool genBusy: false
property bool genRegeneratePending: false
property string genRequestSignature: ""
// `bw serve` state. Ready means the loopback generator answered; failed
// means we stopped trying and the CLI carries the feature instead -- most
// likely because something else already holds the port, in which case we
// must not talk to it: a "generated password" from a stranger's server is
// a password they know.
property bool generateServeReady: false
property bool generateServeStarting: false
property bool generateServeFailed: false
// Set while we are the ones shutting the server down, so its exit is not
// mistaken for the bind failure that gives up on the port.
property bool generateServeStopping: false
property bool generateCliStopping: false
property bool generateServeRequestStopping: false
property bool generateServeRequestPending: false
property var generateServeRequestPendingOptions: null
property var generateServeRequestPendingCallback: null
// Where Back and Esc go, and whether the generator can hand its value
// somewhere. Opened from the item form it fills the password field in and
// returns; opened on its own it is just the generator. One screen either
// way, so the item form offers Bitwarden's own generator rather than a
// second, weaker one of its own.
property string generatorReturnScreen: "main"
readonly property bool generatorFeedsForm: generatorReturnScreen === "edit"
// PIN unlock state
property bool pinConfigured: false // ciphertext present in the keyring
property string pinEntry: "" // locked-screen input
property int pinAttempts: 0
readonly property int pinMaxAttempts: 5
property string pinError: ""
property string pinSetupPin: ""
property string pinSetupConfirm: ""
property string pinSetupMaster: ""
property bool pinBusy: false
property bool pinUnlockSubmitted: false
readonly property bool pinReady: pinUnlock && pinConfigured
// Long enough to save, short enough to be a bad idea. Drives the red state
// on the PIN field during setup; see pinWeakWarning() in BitwardenModel.js.
readonly property bool pinSetupWeak: Model.isPinWeak(pinSetupPin)
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME") || ""
readonly property bool fingerprintReady: fingerprintUnlock && fingerprintAvailable && fingerprintStored
// Contextual suggestions state
property var activeWindowData: null
property var detectedContext: null
property var suggestedItems: []
property bool suggestionsDismissed: false
property var associations: ({ version: 1, keys: {} })
property var learnedIds: ({})
property string pendingAssociationsJson: ""
property bool associationsWritePending: false
property bool associationsClearPending: false
property int associationsEpoch: 0
property int associationsReadEpoch: -1
property bool sessionStorePending: false
property bool sessionClearPending: false
property bool pinClearPending: false
property bool masterClearPending: false
property bool allCredentialsClearPending: false
property bool logoutPending: false
property bool logoutCliDone: false
property bool logoutCredentialsDone: false
property int logoutExitCode: 0
property int logoutCredentialsExitCode: 0
readonly property bool logoutCleanupFailed: logoutPending && logoutCredentialsDone
&& logoutCredentialsExitCode !== 0
// Visual styles
readonly property color fg: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color accent: Color.accent
readonly property color dim: Qt.darker(fg, 1.5)
readonly property color barIconColor: {
var base = bar ? bar.barForeground : Color.foreground
if (status === "unlocked") return Color.accent
if (status === "locked" || status === "checking") return base
return bar ? bar.urgent : Color.urgent
}
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
Component.onCompleted: {
// The dependency probe goes first, and the status probe follows from it in
// onDependenciesChecked. On a machine that already has `bw` the two are a
// few milliseconds apart; on a fresh install the order is the difference
// between opening on the setup screen and opening on a login form that
// cannot succeed.
root.checkDependencies()
root.loadAssociations()
// Explicit as well as bound: onSshAgentSupervisableChanged carries every
// later change, but a shell that starts with the feature already enabled
// evaluates that binding to true once, at creation, with nothing yet
// listening.
root.syncSshAgentSupervision()
// Everything above is the startup value, not a user action. Only changes
// after this point are transitions worth reacting to.
root.sshAgentSettingsReady = true
if (root.sshAgentEnabled) root.inspectSshAgentHelper()
root.inspectUwsmFragment()
}
readonly property var categories: [
{ id: "all", label: "All", icon: "󰞀" },
{ id: "login", label: "Logins", icon: "󰌋" },
{ id: "secureNote", label: "Notes", icon: "󰈙" },
{ id: "card", label: "Cards", icon: "󰿯" },
{ id: "identity", label: "Identities", icon: "" },
{ id: "sshKey", label: "SSH Keys", icon: "󰣀" },
{ id: "favorite", label: "Favorites", icon: "󰓒" }
]
// SSH keys need a CLI that can decrypt them. Until the probe confirms one,
// the type filter that can only ever come back empty is not offered.
readonly property bool sshUiAvailable: Model.sshUiAvailable(dependencies, depsChecked)
readonly property var visibleCategories: sshUiAvailable
? categories
: categories.filter(function(category) { return category.id !== "sshKey" })
// -------------------------------------------------------------------------
// SSH companion supervision
// -------------------------------------------------------------------------
//
// The decisions live in Model.sshAgentReduce(); this side owns the Process,
// the clock and the timers. Every event goes through applySshAgentEvent(),
// which is the only place the state object is replaced, so the mirrored
// properties below and the real state can never drift apart.
//
// Nothing here is on the path of an ordinary vault operation. A helper that
// will not start, will not handshake, or crashes repeatedly leaves login,
// unlock, list, copy, sync, edit, Send and the generator exactly as they
// are; it only closes the signing gate and parks in an error state.
// Resolved from Panel.qml's own URL, so the helper is launched by an
// absolute path inside the plugin directory rather than off PATH.
readonly property string sshAgentPluginDir: Model.pluginDirFromUrl(String(Qt.resolvedUrl(".")))
readonly property string sshAgentRuntimeDir: Quickshell.env("XDG_RUNTIME_DIR") || ""
// What the shipped helper turned out to be. Checked once when the feature
// is enabled, and again whenever the plugin directory changes, because a
// plugin update can replace the binary under a running shell.
property var sshAgentHelper: ({ state: "unknown", source: "", version: "",
protocol: 0, checksum: "unchecked", selfTest: "", message: "" })
readonly property bool sshAgentSupervisable: sshAgentEnabled
&& sshAgentPluginDir !== "" && sshAgentRuntimeDir !== ""
// A helper that fails inspection disables this feature and nothing else:
// no supervisor, so no socket, no FIFO, and no agent branch in the vault
// read. The rest of the plugin never sees it.
&& Model.sshAgentHelperReady(sshAgentHelper)
function inspectSshAgentHelper() {
if (sshAgentHelperProc.running) return
sshAgentHelperProc.command = Model.sshAgentHelperInspectCommand(root.sshAgentPluginDir)
sshAgentHelperProc.running = true
}
function onSshAgentHelperInspected(raw) {
root.sshAgentHelper = Model.parseSshAgentHelperInspection(raw)
}
property var sshAgentState: Model.sshAgentInitialState()
// Mirrors of sshAgentState. QML cannot bind through a plain JS object, and
// the handshake timeout and backoff timers have to be driven by bindings
// rather than by anything that waits.
property string sshAgentPhase: "disabled"
property bool sshAgentGateOpen: false
property string sshAgentSocketPath: ""
property string sshAgentFifoPath: ""
property string sshAgentVersion: ""
property string sshAgentErrorCode: ""
property string sshAgentErrorMessage: ""
function applySshAgentEvent(event) {
var step = Model.sshAgentReduce(root.sshAgentState, event)
root.sshAgentState = step.state
root.sshAgentPhase = step.state.phase
root.sshAgentGateOpen = step.state.gateOpen
root.sshAgentSocketPath = step.state.socketPath
root.sshAgentFifoPath = step.state.fifoPath
root.sshAgentVersion = step.state.agentVersion
root.sshAgentErrorCode = step.state.errorCode
root.sshAgentErrorMessage = step.state.errorMessage
// The state above is committed before any of this runs, because stopping
// the Process can re-enter this function with the child's exit before the
// outer call returns. That order is what makes the re-entry safe: the
// inner reduction sees the phase it should, and no action set here is one
// the inner call also sets.
var action = step.action
// Cancel before scheduling: a stop that arrives while a restart is armed
// must not leave the timer running against a helper nobody asked for.
if (action.cancelRestart) sshAgentRestartTimer.stop()
if (action.stop) stopSshAgentHelper()
if (action.writeHello && sshAgentProc.running) sshAgentProc.write(Model.sshAgentHelloLine())
if (action.restartInMs >= 0) {
sshAgentRestartTimer.interval = action.restartInMs
sshAgentRestartTimer.restart()
}
if (action.start) startSshAgentHelper()
if (action.message) root.onSshAgentMessage(action.message)
}
function startSshAgentHelper() {
sshAgentTerminateTimer.stop()
// A previous stop closed this. The control channel is the helper's only
// input, so it has to be open again before the handshake is written.
sshAgentProc.stdinEnabled = true
sshAgentProc.running = true
}
// Stopping the helper is a request, not a signal. Its designed shutdown is
// the control channel closing: it drops its keys, unlinks its socket and
// FIFO, and exits. SIGTERM -- which is all `running = false` does -- skips
// every one of those, leaving a socket and FIFO behind for the next start
// to clean up. So ask, then terminate only if it does not go.
function stopSshAgentHelper() {
if (!sshAgentProc.running) {
sshAgentTerminateTimer.stop()
return
}
if (sshAgentProc.stdinEnabled) {
sshAgentProc.write(Model.sshAgentShutdownLine())
sshAgentProc.stdinEnabled = false
}
sshAgentTerminateTimer.restart()
}
// Live companion events. Task 10 supervises the channel; the vault
// lifecycle, approval UI and key loading that consume these arrive with
// Tasks 12-14. Until then an unhandled event is deliberately inert rather
// than an error: it is a valid v1 message the panel simply has no use for
// yet.
// -------------------------------------------------------------------------
// Signing authorization
// -------------------------------------------------------------------------
//
// One prompt at a time, never over a locked screen, and never claiming more
// about the requesting process than the companion actually checked.
// What is actually on screen. A live signing request outranks navigation:
// the panel's own flows reset currentScreen freely -- opening the panel,
// finishing an unlock -- and each of those would otherwise drop a prompt
// that a blocked client is waiting on. Screen visibility binds to this
// rather than to currentScreen, so no later assignment can hide a prompt.
readonly property string activeScreen: sshPrompt !== null && !sshAgentApprovalPopup ? "sshApproval" : currentScreen
property var sshPrompt: null // the approval_required being shown
property var sshPromptQueue: [] // FIFO queue of approval_required messages waiting to be shown
property var sshUnlockRequest: null // the unlock_required being shown
property var sshUnlockRaw: null // its original message, to promote from
property var sshUnlockQueue: [] // FIFO queue of unlock_required messages waiting
readonly property int sshPendingCount: Model.sshAgentPendingCount(sshPrompt, sshPromptQueue)
readonly property int sshUnlockPendingCount: Model.sshAgentPendingCount(sshUnlockRequest, sshUnlockQueue)
readonly property int sshTotalPendingCount: sshPendingCount + sshUnlockPendingCount
readonly property bool sshApprovalPopupOpen: sshAgentApprovalPopup
&& (sshPrompt !== null || sshUnlockRequest !== null)
// Password, PIN, and fingerprint completion handlers must accept the
// transient overlay as a real authentication surface even while the
// anchored panel stays closed.
readonly property bool sshAuthSurfaceActive: opened || sshApprovalPopupOpen
// What the companion last announced, and the live view of it. The
// announcement is a snapshot; the view is that snapshot re-derived against
// a ticking clock, so a grant counts down on screen and disappears when it
// lapses instead of waiting for the next thing to happen.
property var sshGrantsAnnounced: []
property double sshGrantTick: 0
readonly property var sshGrants: Model.sshAgentGrantsAt(sshGrantsAnnounced, sshGrantTick)
property var sshCooldown: Model.sshAgentCooldownInitial()
// Whether the current cooldown has already been announced. Reset when it
// lapses, so a later one is announced again but the same one is not
// repeated on every refused request.
property bool sshCooldownAnnounced: false
readonly property var sshCooldownStatus: Model.sshAgentCooldownStatus(sshCooldown, sshCooldownTick)
// A one-second tick so the remaining time in the status actually counts
// down; bindings on Date.now() would never re-evaluate on their own.
property double sshCooldownTick: 0
property double sshPromptStartedMs: 0
property int sshPromptRemainingSec: 0
property string screenBeforeSshApproval: "main"
// Whether the signing request is what put the panel on screen. If it was,
// answering hands the desktop back; if the user already had the panel open,
// it is theirs and they are returned to what they were doing.
property bool sshPromptOpenedPanel: false
function sshAgentWrite(line) {
if (line === "") return
if (sshAgentProc.running && sshAgentProc.stdinEnabled) sshAgentProc.write(line)
}
// Whether a request may raise UI at all. A locked screen never does, and a
// process that has had two refusals in a row is put on a cooldown so it
// cannot keep reopening the panel.
// Called wherever the cooldown may have just started. The announcement is
// the only thing that tells a user why their SSH command suddenly fails.
function noteSshCooldown() {
root.sshCooldownTick = Date.now()
var status = Model.sshAgentCooldownStatus(root.sshCooldown, Date.now())
if (status.active && !root.sshCooldownAnnounced) {
root.sshCooldownAnnounced = true
flashNotification("SSH signing paused: too many unanswered prompts")
} else if (!status.active) {
root.sshCooldownAnnounced = false
}
}
// The only way out of a running cooldown other than waiting it out. It has
// to be explicit: the cooldown suppresses the prompts an approval would
// answer, so nothing the requesting process does can end it, and nothing it
// does should. A person pressing this is the signal that the requests are
// wanted after all.
function resumeSshSigning() {
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "resumed", Date.now())
noteSshCooldown()
}
function sshAgentMayPrompt() {
// An unknown screen state counts as locked. The poll runs every few
// seconds while the agent is serving, so a reading older than this means
// the poll is not running and the panel cannot tell -- and the cost of
// guessing wrong is a credential prompt on a locked desktop.
var fresh = root.screenLockCheckedAt > 0
&& (Date.now() - root.screenLockCheckedAt) < (Model.screenLockPollMs() * 4)
if (!Model.sshAgentShouldPrompt(fresh ? { screenLocked: root.screenIsLocked } : null)) return false
return !Model.sshAgentCooldownActive(root.sshCooldown, Date.now())
}
function showSshApproval(message) {
root.sshPrompt = Model.sshAgentPromptView(message, root.sshAgentApprovalWindowSec)
root.sshPromptStartedMs = Date.now()
root.sshPromptRemainingSec = Math.ceil(Model.sshAgentRequestDeadlineMs() / 1000)
if (root.sshAgentApprovalPopup) {
root.sshPromptOpenedPanel = false
return
}
if (root.currentScreen !== "sshApproval") root.screenBeforeSshApproval = root.currentScreen
// Recorded before opening, because open() is what makes it true.
if (!root.sshUnlockRaw) root.sshPromptOpenedPanel = !root.opened
// Open first. Opening runs onPanelOpened(), which sends an unlocked panel
// to the item list, so claiming the screen before that would simply be
// undone -- the prompt would be live with nothing on screen.
if (!root.opened) root.open()
root.currentScreen = "sshApproval"
}
// shell.json hot-reloads. If the preference changes while a client is
// blocked, move the same request to the newly selected surface rather than
// making it invisible until its deadline expires.
onSshAgentApprovalPopupChanged: {
if (!(root.sshPrompt || root.sshUnlockRequest)) return
if (root.sshAgentApprovalPopup) {
var requestOpenedPanel = root.sshPromptOpenedPanel
root.sshPromptOpenedPanel = false
if (requestOpenedPanel && root.opened) root.close()
return
}
root.sshPromptOpenedPanel = !root.opened
if (!root.opened) root.open()
if (root.sshPrompt) root.currentScreen = "sshApproval"
}
function dismissSshApproval() {
var openedForThis = root.sshPromptOpenedPanel
var popupWasUsed = root.sshApprovalPopupOpen
root.sshPrompt = null
root.sshPromptQueue = []
root.sshPromotedOldId = null
root.sshUnlockRequest = null
root.sshUnlockRaw = null
root.sshUnlockQueue = []
root.sshPromptOpenedPanel = false
if (root.currentScreen === "sshApproval") {
root.currentScreen = root.screenBeforeSshApproval === "sshApproval"
? "main" : root.screenBeforeSshApproval
}
if (popupWasUsed) clearSshPopupUnlockState()
// Answered -- approved or denied alike -- so give the desktop back if the
// request is what took it. A panel the user opened themselves stays open
// on whatever screen they were using.
if (openedForThis && root.opened) root.close()
}
function advanceSshPrompt() {
var res = Model.sshAgentDequeuePrompt(root.sshPromptQueue)
root.sshPromptQueue = res.remaining
if (res.next) {
showSshApproval(res.next)
return
}
dismissSshApproval()
}
function advanceSshUnlock() {
var res = Model.sshAgentDequeuePrompt(root.sshUnlockQueue)
root.sshUnlockQueue = res.remaining
if (res.next) {
root.sshUnlockRaw = res.next
root.sshUnlockRequest = Model.sshAgentPromptView(res.next, 0)
root.sshPromptStartedMs = Date.now()
root.sshPromptRemainingSec = Math.ceil(Model.sshAgentRequestDeadlineMs() / 1000)
return
}
dismissSshApproval()
}
// The popup is deliberately short lived. Do not let a dismissed or expired
// request leave a password, PIN, PAM conversation, or prewarmed CLI behind.
function clearSshPopupUnlockState() {
cancelFingerprintUnlock()
cancelAuthPrewarm()
if (pinUnlockProc.running) pinUnlockProc.running = false
root.pinUnlockSubmitted = false
root.pinBusy = false
root.masterPassword = ""
root.pendingUnlockPassword = ""
root.pendingUnlockFrom = ""
root.pinEntry = ""
root.pinError = ""
root.fingerprintMessage = ""
root.errorMessage = ""
}
function approveSshRequest(grantSeconds) {
if (!sshPrompt) return
sshAgentWrite(Model.sshAgentApproveLine(sshPrompt.requestId, grantSeconds))
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "approved", Date.now())
noteSshCooldown()
advanceSshPrompt()
}
function denySshRequest() {
if (sshUnlockRequest) {
sshAgentWrite(Model.sshAgentUnlockCancelledLine(sshUnlockRequest.requestId))
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "denied", Date.now())
noteSshCooldown()
advanceSshUnlock()
return
}
if (sshPrompt) {
sshAgentWrite(Model.sshAgentDenyLine(sshPrompt.requestId))
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "denied", Date.now())
noteSshCooldown()
advanceSshPrompt()
return
}
dismissSshApproval()
}
function denyAllSshRequests() {
if (sshPrompt) {
sshAgentWrite(Model.sshAgentDenyLine(sshPrompt.requestId))
}
for (var i = 0; i < root.sshPromptQueue.length; i++) {
if (root.sshPromptQueue[i] && root.sshPromptQueue[i].requestId) {
sshAgentWrite(Model.sshAgentDenyLine(root.sshPromptQueue[i].requestId))
}
}
if (sshUnlockRequest) {
sshAgentWrite(Model.sshAgentUnlockCancelledLine(sshUnlockRequest.requestId))
}
for (var j = 0; j < root.sshUnlockQueue.length; j++) {
if (root.sshUnlockQueue[j] && root.sshUnlockQueue[j].requestId) {
sshAgentWrite(Model.sshAgentUnlockCancelledLine(root.sshUnlockQueue[j].requestId))
}
}
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "denied", Date.now())
noteSshCooldown()
dismissSshApproval()
}
// The companion expires the request; this only stops the panel showing a
// question whose answer would now be rejected anyway.
function expireSshRequest() {
if (!sshPrompt && !sshUnlockRequest) return
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "timeout", Date.now())
noteSshCooldown()
dismissSshApproval()
}
// Git SSH signing needs paths, so the validated public set is projected to
// files. Only what the companion vouched for is written, and only its
// public form -- sshExportIdentities() refuses anything that is not an
// OpenSSH public line.
function exportSshPublicKeys() {
var payload = Model.sshExportPayload(root.sshPendingPublicKeys)
root.sshPendingPublicKeys = []
if (sshExportProc.running) return
sshExportProc.running = true
sshExportProc.write(payload)
sshExportProc.stdinEnabled = false
}
// Logout, account change and disabling remove the projection. A lock does
// not: public identities stay advertised while locked, so their files stay
// with them.
function clearSshPublicKeys() {
root.sshPendingPublicKeys = []
root.sshPendingPublicEpoch = -1
if (sshExportClearProc.running) return
sshExportClearProc.running = true
}
function onSshExportFinished(exitCode, stdout) {
var result = Model.parseSshExportResult(exitCode, stdout)
root.sshExportError = result.ok ? "" : result.message
}
property string sshExportError: ""
function revokeSshGrant(grantId) {
sshAgentWrite(Model.sshAgentRevokeGrantLine(grantId))
}
function revokeAllSshGrants() {
sshAgentWrite(Model.sshAgentRevokeGrantsLine())
}
property var sshPromotedOldId: null
function adoptSshPrompt(message) {
if (root.sshPromotedOldId !== null && root.sshPrompt) {
root.sshPrompt.requestId = message.requestId
root.sshPromotedOldId = null
return true
}
return false
}
function onSshAgentMessage(message) {
if (message.type === "approval_required") {
// A request that cannot raise UI is refused rather than left hanging:
// the client gets its answer now instead of waiting out the deadline.
if (!sshAgentMayPrompt()) {
sshAgentWrite(Model.sshAgentDenyLine(message.requestId))
return
}
if (adoptSshPrompt(message)) return
if (root.sshPrompt !== null) {
root.sshPromptQueue = Model.sshAgentEnqueuePrompt(root.sshPromptQueue, message, 4)
return
}
showSshApproval(message)
return
}
if (message.type === "unlock_required") {
if (!sshAgentMayPrompt()) {
sshAgentWrite(Model.sshAgentUnlockCancelledLine(message.requestId))
return
}
if (root.sshUnlockRequest !== null) {
root.sshUnlockQueue = Model.sshAgentEnqueuePrompt(root.sshUnlockQueue, message, 4)
return
}
root.sshUnlockRaw = message
root.sshUnlockRequest = Model.sshAgentPromptView(message, 0)
root.sshPromptStartedMs = Date.now()
root.sshPromptRemainingSec = Math.ceil(Model.sshAgentRequestDeadlineMs() / 1000)
if (root.sshAgentApprovalPopup) {
root.sshPromptOpenedPanel = false
return
}
root.sshPromptOpenedPanel = !root.opened
if (!root.opened) root.open()
return
}
if (message.type === "request_cancelled") {
// The request was cancelled by the client, timed out, or released on unlock.
var live = root.sshPrompt || root.sshUnlockRequest
if (live && live.requestId === message.requestId) {
if (message.reason !== "released") {
root.sshCooldown = Model.sshAgentCooldownAfter(root.sshCooldown, "timeout", Date.now())
noteSshCooldown()
} else {
return
}
if (root.sshPrompt && root.sshPromptQueue.length > 0) advanceSshPrompt()
else if (root.sshUnlockRequest && root.sshUnlockQueue.length > 0) advanceSshUnlock()
else dismissSshApproval()
return
}
if (root.sshPromptQueue.length > 0) {
root.sshPromptQueue = Model.sshAgentRemovePrompt(root.sshPromptQueue, message.requestId)
}
if (root.sshUnlockQueue.length > 0) {
root.sshUnlockQueue = Model.sshAgentRemovePrompt(root.sshUnlockQueue, message.requestId)
}
return
}
if (message.type === "grants_changed") {
root.sshGrantsAnnounced = Model.sshAgentGrantViews(message.grants, Date.now())
root.sshGrantTick = Date.now()
return
}
if (message.type === "public_key") {
// A new epoch starts a new set rather than adding to the last one.
if (root.sshPendingPublicEpoch !== message.epoch) {
root.sshPendingPublicEpoch = message.epoch
root.sshPendingPublicKeys = []
}
root.sshPendingPublicKeys = root.sshPendingPublicKeys.concat([message])
return
}
if (message.type === "keys_loaded") {
root.sshAgentKeyCount = Math.max(0, Math.floor(Number(message.keyCount)) || 0)
root.sshAgentKeysLoadedAt = Date.now()
// The set is complete: every public_key for this epoch arrived ahead of
// this message.
if (root.sshPendingPublicEpoch === message.epoch) exportSshPublicKeys()
return
}
if (message.type === "locked") {
// The companion has denied signing, dropped its grants and private keys,
// and kept only the public projection. That is what the kill timer was
// waiting for.
sshAgentLockAckTimer.stop()
return
}
if (message.type === "state_changed") {
root.sshAgentKeyCount = Math.max(0, Math.floor(Number(message.keyCount)) || 0)
return
}
// unlock_required, approval_required and grants_changed are the signing
// UX, and arrive with Task 14. Ignoring a valid v1 message is deliberate
// here; an unknown *type* is a protocol failure and never reaches this.
}
// -------------------------------------------------------------------------
// Key loading (the agent branch of the shared vault read)
// -------------------------------------------------------------------------
//
// The companion's keystore requires a strictly increasing epoch per load, so
// this counter only ever goes up. It survives helper restarts harmlessly: a
// restarted companion begins again at 0, and every value the panel sends is
// still greater than that.
property int sshAgentEpoch: 0
property string sshAgentLoadId: ""
property bool sshAgentLoadActive: false
// Whether the read now running carries the agent branch, and whether it has
// already been retried without it. The retry exists so an optional feature
// can never cost the user their item list.
property bool listAgentBranchActive: false
property bool listRetriedWithoutAgent: false
// A nonce is generated ahead of the load that will use it. Reading
// /dev/urandom is fast, but it is still a process, and the ordinary item
// list must never wait on the agent feature -- so a load that finds no
// nonce ready simply runs without the branch and primes one for next time.
property string sshAgentNextLoadId: ""
// What the companion last reported it was serving. Public metadata only --
// a count, not the keys -- and it is what tells the panel whether a locked
// companion still has a public cache to answer identity listings from.
property int sshAgentKeyCount: 0
// The validated public identities the companion reported for the epoch
// currently loading. Accumulated per key, because a single message carrying
// all of them would exceed the control-line ceiling at the key limit.
property var sshPendingPublicKeys: []
property int sshPendingPublicEpoch: -1
property double sshAgentKeysLoadedAt: 0
// The vault epoch a key load has already been started for. dropVaultState()
// advances vaultEpoch on every lock and logout, so this is what tells a
// startup load apart from one that has already happened for this session.
property int sshAgentLoadedForVaultEpoch: -1
function primeSshAgentLoadId() {
if (loadIdProc.running || sshAgentNextLoadId !== "") return
loadIdProc.running = true
}
function onSshAgentLoadIdRead(raw) {
var candidate = String(raw || "").trim()
root.sshAgentNextLoadId = Model.isValidLoadId(candidate) ? candidate : ""
}
// Close an open load window. Called on success, on failure, and on a lock
// that cancels the read underneath it. The companion holds every candidate
// unpublished until this arrives, and discards it on a failed status, so a
// window that is never closed is the one outcome to avoid.
function endSshAgentLoad(ok) {
if (!sshAgentLoadActive) return
sshAgentLoadActive = false
sshAgentLoadId = ""
if (sshAgentProc.running && sshAgentProc.stdinEnabled) {
sshAgentProc.write(Model.sshAgentLoadEndLine(sshAgentEpoch, ok))
}
primeSshAgentLoadId()
}
// A lock abandons the current loadId and stops the whole read. The pipeline
// runs as its own process group, so terminating the wrapper reaps `bw`, the
// caps, `tee` and both `jq` stages with it.
function cancelSshAgentLoad() {
if (listProc.running) listProc.running = false
endSshAgentLoad(false)
listAgentBranchActive = false
listRetriedWithoutAgent = false
}
// Every vault transition reaches the companion through here, so the ordering
// rules live in one place: deny first, cancel work in flight, then let the
// panel get on with its own lock. Nothing below ever waits on the helper.
function applySshAgentLifecycle(event) {
var action = Model.sshAgentLifecycleTransition(event, {
enabled: root.sshAgentEnabled,
helperReady: root.sshAgentGateOpen,
loggedIn: root.status !== "unauthenticated",
unlocked: root.status === "unlocked",
loading: root.sshAgentLoadActive,
hasPublicCache: root.sshAgentKeyCount > 0,
epoch: root.sshAgentEpoch
})
if (action.cancelLoad) cancelSshAgentLoad()
for (var i = 0; i < action.controlLines.length; i++) {
if (sshAgentProc.running && sshAgentProc.stdinEnabled) sshAgentProc.write(action.controlLines[i])
}
if (action.clearPublic) {
root.sshAgentKeyCount = 0
root.sshAgentKeysLoadedAt = 0
clearSshPublicKeys()
}
// The acknowledgment is a courtesy the panel gives the companion two
// seconds to return. It is not a precondition for locking: `bw lock` has
// already been launched by the caller, and a companion that cannot
// confirm a lock is one that must not keep running.
if (action.awaitLockAck) sshAgentLockAckTimer.restart()
if (action.stopHelper) stopSshAgentHelper()
if (action.startLoad && !listProc.running) loadItems(false)
}
function syncSshAgentSupervision() {
applySshAgentEvent({ kind: "enabled", value: root.sshAgentSupervisable, nowMs: Date.now() })
}
onSshAgentSupervisableChanged: syncSshAgentSupervision()
function sendSshAgentOptions() {
sshAgentWrite(Model.sshAgentOptionsLine(root.sshAgentUnlockOnDemand))
}
onSshAgentUnlockOnDemandChanged: sendSshAgentOptions()
onSshAgentGateOpenChanged: {
if (sshAgentGateOpen) sendSshAgentOptions()
if (!sshAgentGateOpen) {
endSshAgentLoad(false)
// The keystore lives in the helper's memory. Whatever it held went with
// it, so the panel must stop claiming those keys are still served.
root.sshAgentKeyCount = 0
return
}
// A new helper is empty even when the vault epoch has not moved -- the
// epoch tracks the vault, not the process. Clearing this is what makes a
// restarted or re-enabled helper eligible for a load, instead of leaving
// it keyless until something unrelated happens to bump the epoch.
root.sshAgentLoadedForVaultEpoch = -1
primeSshAgentLoadId()
// Startup is not evidence that the vault is locked: rememberSession can
// restore a session key, so the panel can already be unlocked when the
// companion finishes its handshake with an empty keystore. Deferred by a
// beat so the nonce that was just primed is actually ready.
sshAgentStartupLoadTimer.restart()
}
Timer {
id: sshAgentStartupLoadTimer
interval: 250
repeat: false
onTriggered: root.maybeStartupLoad()
}
// Two things have to be true before a startup load makes sense -- the helper
// is serving, and the vault is actually unlocked -- and on a shell restart
// they arrive in either order: the handshake can easily beat the first
// `bw status`. So both edges call this, and the vault epoch keeps it to one
// load rather than one per edge.
function maybeStartupLoad() {
if (!sshAgentGateOpen || root.status !== "unlocked") return
// A read already running is the common case at startup: the panel's first
// item read is launched before the helper has finished handshaking, so it
// carries no agent branch. onListFinished() calls back here once it lands.
if (sshAgentLoadActive || listProc.running) return
if (sshAgentLoadedForVaultEpoch === root.vaultEpoch) return
// Marked before the attempt, not after it, so one failed attempt cannot
// turn into a read that relaunches itself.
sshAgentLoadedForVaultEpoch = root.vaultEpoch
applySshAgentLifecycle("startup")
}
onStatusChanged: {
promoteUnlockToApproval()
maybeStartupLoad()
}
// The vault is unlocked but its keys are still being read. Ask now rather
// than after: approving needs the key's identity and the requesting
// program, and both are already known. The companion records the approval
// and applies it the moment the keys land, re-checking that the approved
// key is actually present before it signs.
function promoteUnlockToApproval() {
if (root.status !== "unlocked" || !root.sshUnlockRaw || root.sshPrompt) return
// A listing is satisfied by the load itself; there is no signature to
// authorise, so it stays a wait rather than becoming an approval.
if (root.sshUnlockRaw.reason === "list-identities") return
var raw = root.sshUnlockRaw
root.sshPromotedOldId = raw.requestId
root.sshUnlockRequest = null
root.sshUnlockRaw = null
root.sshUnlockQueue = []
showSshApproval(raw)
}
// The bound on the companion's lock acknowledgment. A helper that cannot
// confirm it has dropped its keys is a helper that must not keep running.
Timer {
id: sshAgentLockAckTimer
interval: Model.sshAgentLockAckTimeoutMs()
repeat: false
onTriggered: if (sshAgentProc.running) sshAgentProc.running = false
}
// Disabled / enabled / error, as the design's table defines them. Derived,
// never stored: it can only ever say what the supervisor is actually doing.
readonly property var sshAgentSetup: Model.sshAgentSetupState({
enabled: sshAgentEnabled,
supervisable: sshAgentSupervisable,
phase: sshAgentPhase,
errorCode: sshAgentErrorCode
})
// -------------------------------------------------------------------------
// Client routing (advisory)
// -------------------------------------------------------------------------
//
// Where SSH_AUTH_SOCK points decides nothing above. The companion binds a
// deterministic path and never reads it; this is only about whether the
// user's *clients* will find that socket. The panel sees the graphical
// session's environment and nothing else, so everything here is phrased as
// a hint with a check the user can run in the terminal they actually use.
readonly property string sshAuthSock: Quickshell.env("SSH_AUTH_SOCK") || ""
readonly property var sshRouting: Model.sshAuthSockDiagnostic(sshAuthSock, sshAgentRuntimeDir)
property var uwsmFragment: ({ state: "unknown", removable: false, message: "" })
readonly property var sshRoutingNotice: Model.sshAgentRoutingNotice(uwsmFragment, sshRouting)
property bool uwsmBusy: false
property string uwsmFlash: ""
// Set when the session already points at another agent. Writing the fragment
// would make Bitwarden the primary agent at the next login, which is not
// something to do silently on one click.
property bool uwsmConfirmPending: false
function inspectUwsmFragment() {
if (uwsmInspectProc.running) return
uwsmInspectProc.running = true
}
function beginUwsmSetup() {
if (uwsmBusy) return
if (sshRouting.state === "elsewhere" && !uwsmConfirmPending) {
uwsmConfirmPending = true
return
}
uwsmConfirmPending = false
uwsmBusy = true
uwsmFlash = ""
uwsmWriteProc.running = true
}
// Clearing everything the plugin stored outside its own folder. Confirmed
// rather than absorbed by the first click: it drops a stored master
// password and every learned suggestion, and none of it comes back.
property bool pluginDataConfirmPending: false
property bool pluginDataBusy: false
property string pluginDataFlash: ""
function beginPluginDataRemoval() {
if (pluginDataBusy) return
if (!pluginDataConfirmPending) {
pluginDataConfirmPending = true
return
}
pluginDataConfirmPending = false
pluginDataBusy = true
pluginDataFlash = ""
pluginDataRemoveProc.running = true
}
function cancelPluginDataRemoval() {
pluginDataConfirmPending = false
}
function onPluginDataRemoved(exitCode, stdout) {
var result = Model.parsePluginDataRemoval(exitCode, stdout)
root.pluginDataBusy = false
root.pluginDataFlash = result.message
// The keyring entry is part of what was just deleted, so what the panel
// believes about a stored master password must not be kept.
if (result.ok) root.fingerprintStored = false
}
function cancelUwsmSetup() {
uwsmConfirmPending = false
}
// Safe to call unconditionally: the script removes the file only when it is
// byte-for-byte the one this plugin writes, and refuses a symlink outright.
function removeUwsmFragment() {
if (uwsmBusy) return
uwsmConfirmPending = false
uwsmBusy = true
uwsmFlash = ""
uwsmRemoveProc.running = true
}
function onUwsmActionFinished(exitCode, stdout) {
var result = Model.parseUwsmActionResult(exitCode, stdout)
root.uwsmBusy = false
root.uwsmFlash = result.message
root.inspectUwsmFragment()
}
// Turning the agent off takes the routing file with it, but only if it is
// the exact file this plugin wrote. Anything the user manages by hand is
// left alone with instructions rather than deleted on a toggle.
//
// Gated on startup having finished, because this must fire on a real
// transition and not on the initial evaluation of the binding. Without the
// guard, every shell start with the feature off would delete a routing file
// the user never touched -- a filesystem change nobody asked for.
property bool sshAgentSettingsReady: false
onSshAgentEnabledChanged: {
if (sshAgentEnabled) inspectSshAgentHelper()
inspectUwsmFragment()
if (!sshAgentSettingsReady) return
if (!sshAgentEnabled) {
// Stopping the helper goes through the supervisor, which knows nothing
// about the public projection. Without this, the files of a feature
// that is no longer running are left behind on disk.
applySshAgentLifecycle("disable")
removeUwsmFragment()
return
}
// And turning it back on puts the file back, because taking it away on
// one toggle and not restoring it on the other is a trap: SSH_AUTH_SOCK
// is fixed at login, so the session that flips the setting keeps working
// either way and the damage only appears at the next boot, long past the
// point where anyone would connect the two. The inspection above is
// asynchronous, so the decision waits for its answer.
uwsmRestorePending = true
}
// Only ever set by re-enabling the agent, and cleared by the first
// inspection that follows. It restores what disabling removed; it never
// routes a session that was not already routed, and it never overrules a
// file this plugin did not write.
property bool uwsmRestorePending: false
function applyUwsmRestore() {
if (!uwsmRestorePending) return
uwsmRestorePending = false
if (!sshAgentEnabled || uwsmBusy) return
// "absent" only: a foreign file, a symlink, an unreadable one or no HOME
// are all cases the plugin refuses to touch, and it must keep refusing
// here. An agent already owning SSH_AUTH_SOCK is a decision the user
// makes at the button, with the conflict named.
if (uwsmFragment.state !== "absent" || sshRouting.state === "elsewhere") return
beginUwsmSetup()
}
// -------------------------------------------------------------------------
// Lifecycle & Open / Close
// -------------------------------------------------------------------------
function open() {
errorMessage = ""
flashMessage = ""
revealedFields = ({})
cursorActive = true
showDeleteConfirm = false
totpFollowupActive = false
isUnlocking = false
suggestionsDismissed = false
fingerprintMessage = ""
// controller.show() flips `opened`, which runs onPanelOpened via
// onOpenedChanged. Only drive it directly when the panel was already open
// and that signal will not fire -- otherwise every open did its startup
// work twice, including two `bw status` calls at ~3s each.
var wasOpen = opened
root.controller.show()
if (wasOpen) onPanelOpened()
}
function close() {
errorMessage = ""
revealedFields = ({})
showDeleteConfirm = false
totpFollowupActive = false
isUnlocking = false
cancelAuthPrewarm()
if (pendingSecondFactorLogin()) suspendPendingLogin()
else abandonAuthSecrets()
// Closing a setup form is cancellation even if its keyring writer has
// already started; its completion handler will clear a stale write.
abandonPinSetup()
abandonFingerprintSetup()
cancelFingerprintUnlock()
cancelAttachmentDownloads()
stopGeneratorServe()
root.controller.hide()
}
function toggle() {
if (opened) close()
else open()
}
function detectActiveWindowContext() {
if (!suggestOnOpen) return
activeWindowProc.command = Model.activeWindowCommand()
activeWindowProc.running = true
}
function loadAssociations() {
if (associationsReadProc.running) return
associationsReadEpoch = associationsEpoch
associationsReadProc.command = Model.associationsReadCommand()
associationsReadProc.running = true
}
function onAssociationsLoaded(raw) {
if (associationsReadEpoch !== associationsEpoch) return
associations = Model.parseAssociations(raw)
if (activeWindowData) handleActiveWindowDetected(activeWindowData)
}
function saveAssociations(next) {
associations = next
pendingAssociationsJson = Model.serializeAssociations(next)
if (associationsWriteProc.running) {
associationsWritePending = true
return
}
associationsWritePending = false
associationsWriteProc.running = true
}
// Called whenever the user acts on an item while a window context is active.
// Silent by design: teaching happens as a side effect of normal use.
function learnFromPick(item) {
if (!suggestOnOpen || !item || !item.id || !detectedContext || !Model.isLoginItem(item)) return
if (Model.isAssociated(associations, detectedContext, item.id)) return
saveAssociations(Model.recordAssociation(associations, detectedContext, item.id, new Date().toISOString()))
}
// Explicit pin/unpin from the detail view.
function toggleAssociation(item) {
if (!item || !item.id || !detectedContext || !Model.isLoginItem(item)) return
if (Model.isAssociated(associations, detectedContext, item.id)) {
saveAssociations(Model.forgetAssociation(associations, detectedContext, item.id))
flashNotification("No longer suggested for " + detectedContext.displayName)
} else {
saveAssociations(Model.recordAssociation(associations, detectedContext, item.id, new Date().toISOString()))
flashNotification("Always suggested for " + detectedContext.displayName)
}
if (activeWindowData) handleActiveWindowDetected(activeWindowData)
}
function handleActiveWindowDetected(data) {
activeWindowData = data
if (!suggestOnOpen) {
suggestedItems = []
detectedContext = null
rebuildFilter()
return
}
if (items.length === 0) {
return
}
var res = Model.findContextualMatches(items, data, associations)
detectedContext = res.context
suggestedItems = res.matches
learnedIds = res.learnedIds || ({})
rebuildFilter()
}
// Every field on the login screen, and every field on the unlock screen.
// focusAppropriateField() consults these before it moves the cursor.
function loginFieldHasFocus() {
return emailField.activeFocus || loginPassField.activeFocus
|| code2faField.activeFocus || deviceCodeField.activeFocus
|| serverUrlField.activeFocus
|| apiClientIdField.activeFocus || apiClientSecretField.activeFocus
|| apiMasterField.activeFocus
}
function unlockFieldHasFocus() {
return passField.activeFocus || pinField.activeFocus
}
// Put the cursor somewhere sensible when a screen appears -- not hold it
// there. Those are the same thing right up until something announces a
// screen the user is already typing on, and something does: a logout sets
// the status itself and then runs `bw status` to confirm it, which takes a
// few seconds and arrives to say "unauthenticated" in the middle of the
// master password being typed. Re-focusing on that news moved the cursor
// from the password field to the email field mid-word, so the rest of the
// password went into an unmasked field that was about to be submitted as an
// email address.
//
// So a screen that already holds the cursor keeps it. Moving between screens
// still focuses, because the field holding focus then belongs to the screen
// being left rather than the one arriving.
function focusAppropriateField() {
if (sshApprovalPopupOpen) return
Qt.callLater(function() {
// Setup has no field to type into, and the ones this would reach for are
// on screens that are not showing.
if (currentScreen === "setup") return
if (status === "unlocked" && currentScreen === "main") {
if (!searchField.activeFocus) searchField.forceActiveFocus()
} else if (status === "locked" || status === "checking") {
if (unlockFieldHasFocus()) return
if (pinReady) pinField.forceActiveFocus()
else passField.forceActiveFocus()
} else if (status === "unauthenticated") {
if (loginFieldHasFocus()) return
// A login resumed on a challenge opens on the field that is waiting,
// not back at the top of the form.
if (showDeviceCodeField) deviceCodeField.forceActiveFocus()
else if (show2faField) code2faField.forceActiveFocus()
else if (!show2faMethodPicker) emailField.forceActiveFocus()
}
})
}
onOpenedChanged: {
if (opened) onPanelOpened()
else {
cancelFingerprintUnlock()
cancelAuthPrewarm()
if (pendingSecondFactorLogin()) suspendPendingLogin()
else abandonAuthSecrets()
// A closed panel must not keep a field focused, or the next open would
// count as "already typing here" and skip the field the screen opens on.
keyCatcher.forceActiveFocus()
}
}
function onPanelOpened() {
// A pending login that outlived its window is gone, not resumed.
if (secondFactorStartedAt > 0
&& !Model.secondFactorWindowOpen(secondFactorStartedAt, Date.now())) {
abandonAuthSecrets()
}
focusAppropriateField()
detectActiveWindowContext()
refreshFingerprintAvailability()
// A signing request outranks the item list: it is the reason the panel
// opened, and a client is blocked on the answer.
if (sshPrompt) {
currentScreen = "sshApproval"
return
}
if (status === "unlocked") {
currentScreen = "main"
ensureItemsFresh()
} else if (status === "locked") {
// Still check for a handed-over session: a terminal login leaves the
// panel locked, which is precisely when the handoff matters.
refreshStatus()
prepareUnlock()
startFingerprintUnlock()
} else {
refreshStatus()
}
}
// -------------------------------------------------------------------------
// Status & Keyring Handlers
// -------------------------------------------------------------------------
function refreshStatus() {
errorMessage = ""
if (logoutPending) return
// The dependency probe owns the first status transition. Opening the
// panel before that short probe returns must wait rather than trying to
// execute a CLI that a first-run install may not have yet.
if (!depsChecked) {
checkDependencies()
return
}
// Nothing to ask while a required tool is missing. Every caller reaches
// here on some ordinary event -- a panel open, an IPC nudge -- and none of
// them should be able to walk the user past setup into a login form that
// has no CLI behind it.
if (setupGated) {
currentScreen = "setup"
return
}
// Past the gate, so the vault has been asked about. Recorded here rather
// than at the one call site that waits on the dependency probe, so a panel
// opened before that probe reports does not earn a second `bw status` --
// three seconds each, and the first open is where they are felt.
statusProbeStarted = true
// A terminal login may have left a session waiting. Check before anything
// else, including the locked-with-no-session short circuit below, since
// that is exactly the state a terminal login leaves the panel in.
//
// Only a login this panel actually launched, and only for as long as one
// could still be in progress. Outside that window the file is removed
// rather than read: nobody is expecting a key, so nothing adopts it, and
// leaving a live one in the runtime directory is the worse outcome.
if (sessionHandoffProc.running) return
var expecting = Model.handoffWindowOpen(terminalLoginStartedAt, Date.now())
if (!expecting) terminalLoginStartedAt = 0
beginEpochOperation("sessionHandoff")
sessionHandoffProc.command = Model.sessionHandoffReadCommand(expecting)
sessionHandoffProc.running = true
}
function onSessionHandoff(raw) {
if (epochOperationIsStale("sessionHandoff")) return
var handed = Model.extractSessionToken(String(raw || "").trim())
if (handed) {
cancelAuthPrewarm()
abandonAuthSecrets()
// Consumed, so the window shuts behind it rather than staying open for
// whatever is written there next.
terminalLoginStartedAt = 0
session = handed
vaultEpoch += 1
storeCurrentSession()
// bw minted this key moments ago, so trust it and start loading rather
// than spending another `bw status` (~3.3s) to be told what we know.
// The status check still runs, but alongside the loads instead of in
// front of them -- it only fills in the account email.
status = "unlocked"
currentScreen = "main"
itemsLoadedAt = 0
statusRefreshAfterItems = true
beginInitialVaultLoad(true, false)
resetAutoLockTimer()
focusAppropriateField()
flashNotification("Signed in from the terminal")
return
}
if (status === "locked" && !session) return
if (session) {
runStatusCheck()
} else if (rememberSession && status !== "locked") {
beginEpochOperation("keyringLookup")
keyringLookupProc.command = Model.keyringLookupCommand()
keyringLookupProc.running = true
} else {
runStatusCheck()
}
}
function onKeyringLookupFinished(rawToken) {
if (epochOperationIsStale("keyringLookup")) return
var token = String(rawToken || "").trim()
if (token) {
session = token
vaultEpoch += 1
}
runStatusCheck()
}
function runStatusCheck(authoritative) {
if (statusProc.running) return
statusCheckAuthoritative = authoritative !== false
beginEpochOperation("status")
statusProc.command = Model.statusCommand()
statusProc.running = true
}
// An authentication the user has actually submitted, still running.
function authAttemptInFlight() {
return loginSubmitted || unlockSubmitted
}
function onStatusFinished(rawJson) {
if (epochOperationIsStale("status")) return
// A `bw status` answers about the world as it was when it started, and it
// takes seconds. Landing mid-login, that answer is "unauthenticated" --
// truthfully, for the moment it was asked -- and acting on it cancelled the
// login in flight: SIGTERM to a process the user had just submitted, the
// button dropping back out of "Verifying...", and nothing shown at all. The
// attempt is the newer news; it will set the state itself when it lands.
if (authAttemptInFlight()) {
return
}
isLoading = false
var authoritative = statusCheckAuthoritative
statusCheckAuthoritative = true
var st = Model.parseStatus(rawJson)
if (!authoritative) {
if (st && st.userEmail) {
userEmail = st.userEmail
if (!loginEmail) loginEmail = st.userEmail
}
return
}
if (!st) {
cancelAuthPrewarm()
if (vaultStatePresent()) {
if (session) requestSessionCredentialClear()
dropVaultState()
}
status = "unauthenticated"
currentScreen = "login"
focusAppropriateField()
return
}
userEmail = st.userEmail
if (st.userEmail && !loginEmail) {
loginEmail = st.userEmail
}
if (st.unlocked) {
cancelAuthPrewarm()
abandonAuthSecrets()
status = "unlocked"
currentScreen = "main"
ensureItemsFresh()
resetAutoLockTimer()
focusAppropriateField()
// A vault that has never synced holds no ciphers, so the item list is
// empty and correct -- which looks exactly like a vault with nothing in
// it. `bw login` is supposed to have synced by now, and reports success
// whether or not it managed to: it calls fullSync() without
// allowThrowOnError, so a sync that throws is swallowed, lastSync is
// never set, and the session it prints is a working session onto an
// empty local vault. That is not a state to render as an empty vault,
// so repair it once and reload.
if (!st.lastSync && session && !initialSyncAttempted && !isSyncing) {
initialSyncAttempted = true
syncVault()
}
} else if (st.locked) {
if (vaultStatePresent()) {
if (session) requestSessionCredentialClear()
dropVaultState()
}
status = "locked"
currentScreen = "locked"
focusAppropriateField()
if (sshAuthSurfaceActive) prepareUnlock()
if (sshAuthSurfaceActive) startFingerprintUnlock()
} else {
cancelAuthPrewarm()
if (vaultStatePresent()) {
if (session) requestSessionCredentialClear()
dropVaultState()
}
status = "unauthenticated"
currentScreen = "login"
focusAppropriateField()
}
}
// -------------------------------------------------------------------------
// In-Plugin Login & Authentication
// -------------------------------------------------------------------------
function emailLoginSignature() {
return String(loginEmail || "").trim() + "\n"
+ resolvedLoginServerUrl() + "\n"
+ (String(login2faCode || "").trim() ? "2fa" : "plain") + "\n"
+ String(login2faMethod)
}
function resolvedLoginServerUrl() {
return Model.loginServerUrlFor(loginServerRegion, loginServerUrl)
}
function selectLoginServerRegion(region) {
if (loginServerRegion === region) return
loginServerRegion = region
errorMessage = ""
resetEmailLoginSecondFactor()
invalidateEmailLoginPrewarm()
}
function invalidateEmailLoginPrewarm() {
if (loginSubmitted) return
if (loginSubmitAfterPrewarmStop) isLoading = false
loginSubmitAfterPrewarmStop = false
loginPrepareAfterPrewarmStop = false
loginPrewarmSignature = ""
if (loginProc.running) loginProc.running = false
}
function resetEmailLoginSecondFactor() {
show2faField = false
login2faCode = ""
loginDeviceVerification = false
show2faMethodPicker = false
login2faMethodConfirmed = false
showDeviceCodeField = false
loginDeviceCode = ""
// Back to the remembered method, not to nothing: a fresh attempt should
// start from what worked last time.
login2faMethod = rememberedTwoFactorMethod
syncLoginFieldsToState()
}
// The user answering bw's provider question. The pick is not trusted yet --
// it is sent on its own first, without a code, which makes bw either mail
// the code (Email), accept it silently (Authenticator, YubiKey), or say the
// account does not have it. So a wrong pick costs nothing typed.
function chooseTwoFactorMethod(method) {
if (!Model.isTwoFactorMethod(method)) return
errorMessage = ""
login2faMethod = method
login2faMethodConfirmed = true
show2faMethodPicker = false
show2faField = false
login2faCode = ""
submitLogin()
}
// Answering bw's new-device prompt, which is the only challenge it will not
// take from a flag. The code the user just typed goes to the command's
// environment, the password down the usual FIFO, and bw runs with its
// prompts enabled for this one call.
function submitDeviceVerification() {
if (loginSubmitted) return
var code = String(loginDeviceCode || "").trim()
if (!code) {
errorMessage = "Enter the code Bitwarden emailed you."
Qt.callLater(function() { deviceCodeField.forceActiveFocus() })
return
}
if (!String(loginPassword || "")) {
errorMessage = "Your master password is needed again for this step."
resetEmailLoginSecondFactor()
Qt.callLater(function() { loginPassField.forceActiveFocus() })
return
}
errorMessage = ""
isLoading = true
// A prewarmed process was started for the ordinary login and cannot answer
// this; stop it and start the interactive one when it is gone.
if (loginProc.running) {
deviceVerificationPending = true
loginSubmitAfterPrewarmStop = false
loginPrepareAfterPrewarmStop = false
loginProc.running = false
return
}
startDeviceVerificationLogin()
}
function startDeviceVerificationLogin() {
deviceVerificationPending = false
loginPrewarmSignature = ""
loginAttemptHadCode = false
loginAttemptMethod = login2faMethod
// Set before the process starts, because both the environment binding and
// the exit handler read it.
deviceVerificationAttempt = true
loginProc.command = Model.deviceVerificationLoginCommand(
String(loginEmail || "").trim(), resolvedLoginServerUrl(), login2faMethod)
loginProc.running = true
loginSubmitted = true
writeAuthPassword("login", loginPassword)
}
// A login stopped on a challenge it cannot answer without leaving the panel.
// Only these survive a close, only while the window is open, and only while
// there is still a password to submit with the answer.
function pendingSecondFactorLogin() {
if (status !== "unauthenticated" || loginMethod !== "email") return false
if (!show2faField && !showDeviceCodeField && !show2faMethodPicker) return false
if (!String(loginPassword || "")) return false
return Model.secondFactorWindowOpen(secondFactorStartedAt, Date.now())
}
// Typing into a TextField assigns to its own `text`, which breaks the binding
// back to the property behind it. After that the two are independent, and
// clearing the property alone leaves the field showing what was typed --
// while every submit reads the property. That is exactly how a login came to
// be sent with no code at all while the user was looking at a filled-in
// field: bw answered "Code is required.", the panel reported the code as
// rejected, and retyping it repaired the property so the next click worked.
//
// So a field is never cleared by clearing what is behind it. These go
// together, always.
function syncLoginFieldsToState() {
code2faField.text = login2faCode
deviceCodeField.text = loginDeviceCode
loginPassField.text = loginPassword
apiMasterField.text = loginPassword
apiClientIdField.text = loginClientId
apiClientSecretField.text = loginClientSecret
}
// Closing on a challenge keeps the stage and the password, and drops the
// code -- whatever was half-typed before going to look it up is not the code
// that is about to be read.
function suspendPendingLogin() {
login2faCode = ""
loginDeviceCode = ""
loginSubmitted = false
isLoading = false
syncLoginFieldsToState()
}
// What a stopped login process owes whoever stopped it. `mayScrub` is false
// when the run that just ended was itself the scrub, so one cannot schedule
// another.
function resumeDeferredLogin(mayScrub) {
if (deviceVerificationPending) {
deviceVerificationPending = false
Qt.callLater(startDeviceVerificationLogin)
} else if (loginSubmitAfterPrewarmStop) {
loginSubmitAfterPrewarmStop = false
Qt.callLater(submitLogin)
} else if (loginPrepareAfterPrewarmStop) {
loginPrepareAfterPrewarmStop = false
Qt.callLater(prepareEmailLogin)
} else if (mayScrub) {
clearProcessCollectorSoon(loginProc)
}
}
function markSecondFactorStage() {
secondFactorStartedAt = Date.now()
}
function reopenTwoFactorMethodPicker() {
errorMessage = ""
show2faField = false
login2faCode = ""
show2faMethodPicker = true
markSecondFactorStage()
}
function emailLoginButtonText() {
if (logoutCleanupFailed) return "Retry Logout Cleanup"
if (logoutPending) return "Finishing logout..."
if (isLoading) return show2faField ? "Verifying..." : "Logging in..."
return show2faField ? "Verify & Unlock" : "Log In & Unlock"
}
function prepareEmailLogin() {
if (logoutPending || !opened || status !== "unauthenticated" || loginMethod !== "email" || isLoading) return
var email = String(loginEmail || "").trim()
var serverUrl = resolvedLoginServerUrl()
if (!email || Model.validateServerUrl(serverUrl)) return
// Configuring a custom server changes bw's persistent global state. Do it
// only after explicit submission, never merely because the password field
// received focus. Default-cloud logins still get the full prewarm win.
if (serverUrl) return
var signature = emailLoginSignature()
if (loginProc.running) {
if (loginPrewarmSignature === signature) return
loginPrepareAfterPrewarmStop = true
loginProc.running = false
return
}
loginPrepareAfterPrewarmStop = false
loginPrewarmSignature = signature
loginSubmitted = false
deviceVerificationAttempt = false
loginAttemptHadCode = String(login2faCode || "").trim().length > 0
loginAttemptMethod = login2faMethod
loginProc.command = Model.emailLoginPrewarmCommand(
email, loginAttemptHadCode, serverUrl, login2faMethod)
loginProc.running = true
}
function prepareUnlock() {
if (!sshAuthSurfaceActive || status !== "locked" || unlockProc.running) return
unlockSubmitted = false
unlockProc.command = Model.unlockPrewarmCommand()
unlockProc.running = true
}
function cancelAuthPrewarm() {
authPasswordWriteTarget = ""
authPasswordWriteValue = ""
unlockSubmitted = false
loginSubmitted = false
loginSubmitAfterPrewarmStop = false
loginPrepareAfterPrewarmStop = false
loginPrewarmSignature = ""
if (authPasswordWriterProc.running) authPasswordWriterProc.running = false
if (unlockProc.running) unlockProc.running = false
if (loginProc.running) loginProc.running = false
}
function abandonAuthSecrets() {
masterPassword = ""
loginPassword = ""
loginClientId = ""
loginClientSecret = ""
login2faCode = ""
show2faField = false
loginDeviceVerification = false
loginAttemptHadCode = false
show2faMethodPicker = false
login2faMethodConfirmed = false
login2faMethod = rememberedTwoFactorMethod
loginAttemptMethod = -1
showDeviceCodeField = false
loginDeviceCode = ""
deviceVerificationAttempt = false
deviceVerificationPending = false
secondFactorStartedAt = 0
loginPasswordRetryUsed = false
pendingUnlockPassword = ""
pendingUnlockFrom = ""
authPasswordWriteValue = ""
pinEntry = ""
pinUnlockSubmitted = false
fingerprintAuthorized = false
syncLoginFieldsToState()
}
function writeAuthPassword(channel, password) {
authPasswordWriteTarget = channel
authPasswordWriteValue = String(password === undefined || password === null ? "" : password)
authPasswordWriterProc.command = Model.authPasswordWriteCommand(channel)
authPasswordWriterProc.running = true
}
function onAuthPasswordWriterExited(exitCode) {
var target = authPasswordWriteTarget
authPasswordWriteTarget = ""
authPasswordWriteValue = ""
if (exitCode === 0) {
loginPasswordRetryUsed = false
return
}
if (!target) return
if (target === "unlock") {
unlockSubmitted = false
isUnlocking = false
if (unlockProc.running) unlockProc.running = false
errorMessage = "Could not deliver the password to Bitwarden. Please try again."
Qt.callLater(prepareUnlock)
} else if (target === "login") {
loginSubmitted = false
isLoading = false
if (loginProc.running) loginProc.running = false
// The writer polls for bw's FIFO and gives up if bw has not opened it in
// time, which a cold start after the panel has been closed can outrun.
// Unlock has always re-armed itself here; login left the button for the
// user to press again, which is what having to click Verify twice was.
// Once, so a genuinely broken delivery still reports rather than looping.
if (!loginPasswordRetryUsed) {
loginPasswordRetryUsed = true
var retryDevice = deviceVerificationAttempt
deviceVerificationAttempt = false
Qt.callLater(retryDevice ? submitDeviceVerification : submitLogin)
return
}
errorMessage = "Could not deliver the password to Bitwarden. Please try again."
}
}
function submitLogin() {
if (loginSubmitted) return
errorMessage = ""
if (logoutPending) {
errorMessage = "Finishing logout. Please wait a moment."
return
}
// Checked before either branch, because both send the master password to
// whatever this names. See validateServerUrl() for what it refuses.
var serverUrl = resolvedLoginServerUrl()
var serverProblem = Model.validateServerUrl(serverUrl)
if (serverProblem) {
errorMessage = serverProblem
return
}
if (loginMethod === "email") {
var email = String(loginEmail || "").trim()
var pass = String(loginPassword === undefined || loginPassword === null ? "" : loginPassword)
if (!email) {
errorMessage = "Email address is required"
return
}
if (!pass) {
errorMessage = "Master password is required"
return
}
if (show2faMethodPicker) {
errorMessage = "Choose a two-step method to continue."
return
}
if (show2faField && !String(login2faCode || "").trim()) {
errorMessage = "Two-step verification code is required"
Qt.callLater(function() { code2faField.forceActiveFocus() })
return
}
isLoading = true
deviceVerificationAttempt = false
var signature = emailLoginSignature()
if (loginProc.running && loginPrewarmSignature !== signature) {
loginPrepareAfterPrewarmStop = false
loginSubmitAfterPrewarmStop = true
loginProc.running = false
return
}
if (!loginProc.running) {
loginPrewarmSignature = signature
loginAttemptHadCode = login2faCode.trim().length > 0
loginAttemptMethod = login2faMethod
loginProc.command = Model.emailLoginPrewarmCommand(
email, loginAttemptHadCode, serverUrl, login2faMethod)
loginProc.running = true
}
loginSubmitted = true
writeAuthPassword("login", pass)
} else {
var id = String(loginClientId || "").trim()
var secret = String(loginClientSecret || "").trim()
var pass2 = String(loginPassword === undefined || loginPassword === null ? "" : loginPassword)
if (!id) {
errorMessage = "API Client ID is required"
return
}
if (!secret) {
errorMessage = "API Client Secret is required"
return
}
if (!pass2) {
errorMessage = "Master password is required to unlock vault"
return
}
isLoading = true
if (loginProc.running) {
loginPrepareAfterPrewarmStop = false
loginSubmitAfterPrewarmStop = true
loginProc.running = false
return
}
// Client ID, client secret and password all travel in the environment.
loginSubmitted = true
loginPrewarmSignature = ""
loginAttemptHadCode = false
loginAttemptMethod = -1
loginProc.command = Model.apiKeyLoginCommand(serverUrl)
loginProc.running = true
}
}
// Every exit from onLoginOutput says which branch it took. Read with:
// quickshell log -f | grep qs-bitwarden
function logLogin(branch, out, err, exitCode) {
console.log("qs-bitwarden login " + Model.loginDiagnostic(out, err, exitCode, branch))
}
function onLoginOutput(stdoutText, stderrText, exitCode) {
isLoading = false
loginPrewarmSignature = ""
var out = String(stdoutText || "").trim()
var err = String(stderrText || "").trim()
var wasDeviceAttempt = deviceVerificationAttempt
deviceVerificationAttempt = false
// The interactive login answers for itself. Its output is a prompt session
// rather than one of bw's one-line refusals, so none of the detectors
// below should be allowed to read it.
if (wasDeviceAttempt && !(exitCode === 0 && out.length > 10)) {
var detail = Model.sanitizeInteractiveStderr(err, loginDeviceCode)
loginDeviceCode = ""
loginDeviceVerification = true
// 124 is `timeout`; the prompt error is inquirer finding nothing left to
// read. Both mean bw wanted something this login could not give it, and
// a terminal is the only thing that can.
if (exitCode === 124 || Model.loginPromptRanOutOfInput(out, err)) {
showDeviceCodeField = false
logLogin("device-unanswerable", out, err, exitCode)
errorMessage = "This login asked for something the panel could not answer. "
+ "Finish it in a terminal instead."
return
}
logLogin("device-code-rejected", out, err, exitCode)
showDeviceCodeField = true
markSecondFactorStage()
errorMessage = detail
? "Device verification failed: " + detail
: "That verification code was not accepted. Use the newest email and try again."
Qt.callLater(function() { deviceCodeField.forceActiveFocus() })
return
}
// Checked before the second-factor branch, which matches the same sentence.
// A code went out and bw still says a code is required, so this is the
// new-device challenge -- asking for the code again would loop forever on
// one bw cannot be given. The terminal login can answer it.
if (Model.loginNeedsDeviceVerification(out, err, loginAttemptHadCode)) {
resetEmailLoginSecondFactor()
loginDeviceVerification = true
showDeviceCodeField = true
markSecondFactorStage()
errorMessage = "Bitwarden needs to verify this device. Enter the code it emailed you."
logLogin("device-verification", out, err, exitCode)
Qt.callLater(function() { deviceCodeField.forceActiveFocus() })
return
}
// No --method can answer this one and no terminal helps: the account's
// two-step methods are ones the CLI cannot perform at all.
if (Model.loginHasNoUsableProvider(out, err)) {
resetEmailLoginSecondFactor()
logLogin("no-usable-provider", out, err, exitCode)
errorMessage = "This account's two-step method is one the Bitwarden CLI cannot use, "
+ "such as a passkey or Duo. Log in with an API key instead."
return
}
// bw asking which two-step method to use. Answering it by guessing is what
// costs a real failed attempt, so the panel puts the question to the user.
if (Model.loginNeedsMethodChoice(out, err)) {
// A method that was only remembered, never confirmed against this
// account, is the likeliest thing to be wrong here -- shell.json holds
// one method for whichever account logged in last. Drop it and let the
// untargeted attempt say what this account actually needs. The method
// only ever goes from set to unset here, so this cannot loop.
if (Model.isTwoFactorMethod(loginAttemptMethod) && !login2faMethodConfirmed) {
forgetTwoFactorMethod()
login2faMethod = -1
loginAttemptMethod = -1
logLogin("method-stale-retry", out, err, exitCode)
Qt.callLater(submitLogin)
return
}
var rejectedMethod = login2faMethodConfirmed
? Model.twoFactorMethodLabel(loginAttemptMethod) : ""
show2faField = false
login2faCode = ""
login2faMethod = -1
login2faMethodConfirmed = false
show2faMethodPicker = true
markSecondFactorStage()
errorMessage = rejectedMethod
? "Bitwarden does not have " + rejectedMethod + " set up for this account. "
+ "Choose another method."
: "This account has more than one two-step method. Choose the one you use."
logLogin("method-choice", out, err, exitCode)
return
}
if (Model.loginNeedsSecondFactor(out, err)) {
// A code must never be sent without the method it belongs to. bw only
// puts the token on the wire when a provider came with it, so without
// --method the first request is a bare password grant -- and for an
// email provider the server answers that by issuing a fresh code,
// invalidating the one the user is about to type. Confirmed against
// bw 2026.2.0: the same command with --method succeeds and without it
// returns "Two-step token is invalid."
//
// The method cannot be inferred, so it is asked for once per account
// before any code is collected. An authenticator would survive being
// asked in the wrong order; an emailed code would not.
if (!Model.isTwoFactorMethod(login2faMethod)) {
show2faField = false
login2faCode = ""
show2faMethodPicker = true
markSecondFactorStage()
syncLoginFieldsToState()
errorMessage = "Two-step verification is required. Choose the method this account uses."
logLogin("second-factor-needs-method", out, err, exitCode)
return
}
var secondFactorWasVisible = show2faField
show2faMethodPicker = false
show2faField = true
markSecondFactorStage()
logLogin("second-factor", out, err, exitCode)
errorMessage = secondFactorWasVisible
? "That two-step verification code was not accepted. Please try again."
: "Two-step verification is required. Enter your code to continue."
Qt.callLater(function() { code2faField.forceActiveFocus() })
return
}
if (exitCode === 0 && out.length > 10) {
rememberTwoFactorMethod(login2faMethod)
loginPassword = ""
login2faCode = ""
logLogin("success", out, err, exitCode)
onUnlockSuccess(out)
return
}
if (err) {
logLogin("bw-error", out, err, exitCode)
errorMessage = err
} else if (exitCode !== 0) {
logLogin("failed-no-stderr", out, err, exitCode)
errorMessage = "Login failed. Please check your credentials."
} else {
// bw exited cleanly and said nothing at all. Handing that to the unlock
// path was silent by construction: prepareUnlock() refuses it because
// the vault is not locked, so the password went to a FIFO nobody had
// created and failed two seconds later, after the next click had already
// cleared the message. Say what happened instead.
logLogin("clean-exit-no-session", out, err, exitCode)
errorMessage = "Bitwarden reported no error but returned no session. "
+ "Please try again, or use the terminal login."
}
}
function launchTerminalLogin() {
if (logoutPending) {
errorMessage = "Finishing logout. Please wait a moment."
return
}
// The panel knows whether this is a login or an unlock, so the terminal
// does not have to spend a `bw status` round trip working it out.
var mode = (status === "locked") ? "unlock" : "login"
var serverUrl = mode === "login" ? resolvedLoginServerUrl() : ""
var serverProblem = Model.validateServerUrl(serverUrl)
if (serverProblem) {
errorMessage = serverProblem
return
}
close()
// Opens the window in which a handed-over session key is accepted. See
// refreshStatus().
terminalLoginStartedAt = Date.now()
Quickshell.execDetached(Model.terminalLoginCommand(mode, serverUrl))
}
function logoutAccount() {
if (logoutPending) return
logoutPending = true
logoutCliDone = false
logoutCredentialsDone = false
logoutExitCode = 0
logoutCredentialsExitCode = 0
terminalLoginStartedAt = 0
lockVault()
// Stronger than the lock above: logout takes the public projection with
// it, so a new account cannot inherit the last one's identities.
applySshAgentLifecycle("logout")
forgetStoredCredentials()
pendingUnlockPassword = ""
logoutProc.command = Model.logoutCommand()
logoutProc.running = true
status = "unauthenticated"
currentScreen = "login"
userEmail = ""
}
function onLogoutCliFinished(exitCode) {
if (!logoutPending) return
logoutExitCode = exitCode
logoutCliDone = true
finishLogoutIfReady()
}
function onLogoutCredentialsFinished(exitCode) {
if (!logoutPending) return
logoutCredentialsExitCode = exitCode
logoutCredentialsDone = true
finishLogoutIfReady()
}
function finishLogoutIfReady() {
if (!logoutPending || !logoutCliDone || !logoutCredentialsDone) return
if (logoutCredentialsExitCode !== 0) {
errorMessage = "Could not clear stored credentials. Retry logout cleanup before signing in."
return
}
logoutPending = false
status = "unauthenticated"
currentScreen = "login"
if (logoutExitCode === 0) flashNotification("Logged out")
else errorMessage = "Bitwarden logout did not complete cleanly. Please try again."
focusAppropriateField()
}
function retryLogoutCleanup() {
if (!logoutCleanupFailed) return
errorMessage = ""
logoutCredentialsDone = false
logoutCredentialsExitCode = 0
requestAllCredentialClear()
}
function storeCurrentSession() {
if (logoutPending) {
sessionStorePending = false
return
}
if (!rememberSession || !session) {
sessionStorePending = false
return
}
if (keyringStoreProc.running || keyringClearProc.running) {
sessionStorePending = true
return
}
sessionStorePending = false
beginEpochOperation("sessionStore")
keyringStoreProc.running = true
}
function onSessionStored(exitCode) {
if (epochOperationIsStale("sessionStore") || status !== "unlocked" || !session) {
sessionStorePending = rememberSession && status === "unlocked" && !!session
requestSessionCredentialClear()
return
}
sessionStorePending = false
if (exitCode !== 0) {
console.warn("qs-bitwarden-cli: could not store session in keyring (exit " + exitCode + ")")
}
}
function requestSessionCredentialClear() {
if (keyringClearProc.running) {
sessionClearPending = true
return
}
sessionClearPending = false
keyringClearProc.running = true
}
function requestPinCredentialClear() {
if (keyringClearPinProc.running) {
pinClearPending = true
return
}
pinClearPending = false
keyringClearPinProc.running = true
}
function requestMasterCredentialClear() {
if (keyringClearMasterProc.running) {
masterClearPending = true
return
}
masterClearPending = false
keyringClearMasterProc.running = true
}
function credentialStoresRunning() {
return keyringStoreProc.running || pinStoreProc.running || keyringStoreMasterProc.running
}
function requestAllCredentialClear() {
if (keyringClearAllProc.running) {
allCredentialsClearPending = true
return
}
// A clear that wins the race against an older store is not cleanup: that
// store can recreate the credential immediately afterward. Logout remains
// pending until every writer has exited and this final sweep has run.
if (credentialStoresRunning()) {
allCredentialsClearPending = true
return
}
allCredentialsClearPending = false
keyringClearAllProc.running = true
}
// Logging out takes the keyring with it. Two of the entries there are the
// master password -- fingerprint unlock keeps it as it is, PIN unlock keeps
// it encrypted -- and both are written to the default collection so they
// survive a reboot, which is exactly why a logout has to be the end of them.
//
// Nothing here asks whether we think an entry exists. `fingerprintStored`
// and `pinConfigured` describe what the settings screen last saw, and both
// go false for reasons that leave the keyring untouched: an unplugged
// reader, an uninstalled fprintd, a dependency probe that has not answered
// yet. Gating the clear on them is how a master password came to outlive the
// account it belonged to. See keyringClearAllCommand() for why asking
// unconditionally is free.
function forgetStoredCredentials() {
requestAllCredentialClear()
// The learned-suggestion store is this account's data too -- which domains
// and apps it holds logins for, and when each was last used -- and unlike
// everything else here it is a plain file with no expiry. It goes with the
// account rather than waiting for the next user of this machine to read it.
associationsEpoch += 1
pendingAssociationsJson = ""
associationsWritePending = false
if (associationsWriteProc.running) {
associationsClearPending = true
associationsWriteProc.running = false
} else {
associationsClearPending = false
associationsClearProc.running = true
}
associations = Model.emptyAssociations()
suggestedItems = []
detectedContext = null
activeWindowData = null
cancelFingerprintUnlock()
fingerprintStored = false
fingerprintMessage = ""
pinConfigured = false
pinEntry = ""
pinAttempts = 0
pinError = ""
if (pinUnlock) writeSetting("pinUnlock", false, "bool")
}
// -------------------------------------------------------------------------
// Fingerprint Unlock
// -------------------------------------------------------------------------
// Secrets go to secret-tool through the environment, never argv. See
// keyringStoreScript() in BitwardenModel.js for why stdin is not usable.
function associationsEnv() {
var env = {}
env[Model.associationsEnvVar()] = String(pendingAssociationsJson || "")
return env
}
// BW_SESSION rather than --session: bw reads it natively, and it keeps the
// token out of /proc/<pid>/cmdline, which any local user can read.
function bwEnv(extra) {
var env = {}
if (session) env[Model.sessionEnvVar()] = String(session)
if (extra) for (var k in extra) env[k] = extra[k]
return env
}
// Authentication credentials enter short-lived processes through the
// environment. Direct password flows move BW_PASSWORD from the writer into
// bw's private FIFO; API login reads BW_PASSWORD, BW_CLIENTID and
// BW_CLIENTSECRET natively. None reaches an argv -- neither bw's nor that of
// the shell wrapping it.
// /proc/<pid>/cmdline is world-readable on a default install; environ is not.
//
// Read as a binding by loginProc and unlockProc, so it always reflects the
// fields as they are when the process starts.
function authEnv(password, clientId, clientSecret, code) {
var env = bwEnv()
env[Model.noInteractionEnvVar()] = "true"
if (password) env[Model.passwordEnvVar()] = String(password)
if (clientId) env[Model.clientIdEnvVar()] = String(clientId)
if (clientSecret) env[Model.clientSecretEnvVar()] = String(clientSecret)
// The only one bw has no environment option for; see the comment on
// TWOFACTOR_CODE_ENV in BitwardenModel.js.
if (code) env[Model.twoFactorCodeEnvVar()] = String(code)
return env
}
function loginProcessEnv() {
if (loginMethod === "apikey") {
// This is a live Process binding. Keep fields out of its retained value
// until an actual API login starts, instead of duplicating credentials
// into both the form and the process object while the user is typing.
if (!loginSubmitted) return authEnv("", "", "", "")
return authEnv(loginPassword,
String(loginClientId || "").trim(),
String(loginClientSecret || "").trim(),
String(login2faCode || "").trim())
}
// The one login allowed to prompt. BW_NOINTERACTION is left out rather
// than set to anything, since bw tests it against the literal "true", and
// the code goes in for the command's own printf to read -- authEnv() is
// not used here precisely because it would put the flag back.
if (deviceVerificationAttempt) {
var deviceEnv = bwEnv()
deviceEnv[Model.deviceCodeEnvVar()] = String(loginDeviceCode || "").trim()
return deviceEnv
}
// Email/password login reads its password from the FIFO writer. Keeping it
// out of the long-lived prewarmed process also keeps partial typing out of
// that process's environment.
return authEnv("", "", "", String(login2faCode || "").trim())
}
function itemEnv() {
var e = {}
e[Model.itemEnvVar()] = String(itemPayloadJson || "")
return bwEnv(e)
}
function folderEnv() {
var e = {}
e[Model.folderEnvVar()] = Model.folderPayload(newFolderName)
return bwEnv(e)
}
function sendEnv(json) {
var e = {}
e[Model.sendEnvVar()] = String(json || "")
return bwEnv(e)
}
function pinEnv(pin, secret) {
var env = {}
env[Model.pinEnvVar()] = String(pin || "")
if (secret) env[Model.keyringSecretEnvVar()] = String(secret)
return env
}
function secretEnv(value) {
var env = {}
env[Model.keyringSecretEnvVar()] = String(value || "")
return env
}
// -------------------------------------------------------------------------
// Bitwarden Send
// -------------------------------------------------------------------------
function openSends() {
closeFilterGroup()
sendMode = "list"
sendError = ""
sendIndex = 0
currentScreen = "sends"
loadSends()
}
function loadSends() {
if (!session) return
sendsLoading = true
beginVaultRead("sends")
listSendsProc.command = Model.listSendsCommand()
listSendsProc.running = true
}
function onSendsLoaded(raw) {
sendsLoading = false
if (vaultReadIsStale("sends")) return
sends = Model.parseSends(raw)
if (sendIndex >= sends.length) sendIndex = Math.max(0, sends.length - 1)
}
function beginCreateSend() {
sendFormName = ""
sendFormText = ""
sendFormHidden = false
sendFormDays = 7
sendFormMaxAccess = 0
sendFormPassword = ""
sendError = ""
sendMode = "create"
Qt.callLater(function() { sendNameField.forceActiveFocus() })
}
function submitCreateSend() {
if (!String(sendFormText || "").trim()) {
sendError = "Nothing to send -- enter some text"
return
}
sendError = ""
sendBusy = true
sendPayloadJson = JSON.stringify(Model.buildSendPayload(
sendFormName, sendFormText, sendFormHidden,
sendFormDays, sendFormMaxAccess, sendFormPassword, ""))
beginVaultRead("sendCreate")
createSendProc.command = Model.createSendCommand()
createSendProc.running = true
}
function onSendCreated(exitCode, stdoutText, stderrText) {
sendBusy = false
sendPayloadJson = ""
if (vaultReadIsStale("sendCreate")) return
if (exitCode !== 0) {
sendError = String(stderrText || "").trim() || "Could not create the Send"
return
}
// bw prints the access URL; put it straight on the clipboard, since a Send
// is useless until the link reaches someone.
var created = null
try { created = JSON.parse(stdoutText) } catch (e) { created = null }
var url = created && created.accessUrl ? String(created.accessUrl) : String(stdoutText || "").trim()
if (url) {
copyToClipboard(url, "Send link")
} else {
flashNotification("Send created")
}
sendFormText = ""
sendFormPassword = ""
sendMode = "list"
loadSends()
}
function copySendLink(send) {
if (!send || !send.accessUrl) return
copyToClipboard(send.accessUrl, "Send link")
}
function deleteSend(send) {
if (!send || !send.id) return
sendBusy = true
beginVaultRead("sendDelete")
deleteSendProc.command = Model.deleteSendCommand(send.id)
deleteSendProc.running = true
}
function onSendDeleted(exitCode) {
sendBusy = false
if (vaultReadIsStale("sendDelete")) return
if (exitCode !== 0) {
sendError = "Could not delete the Send"
return
}
flashNotification("Send deleted")
loadSends()
}
function moveSendCursor(delta) {
if (sends.length === 0) return
sendIndex = Math.max(0, Math.min(sends.length - 1, sendIndex + delta))
}
// -------------------------------------------------------------------------
// Generator
// -------------------------------------------------------------------------
// Reached from the header button on any screen and from the item form's
// Generate button, which is the same thing: the form is just a caller that
// wants the value back.
function openGenerator() {
closeFilterGroup()
generatorReturnScreen = (currentScreen === "edit") ? "edit" : "main"
screenBeforeSettings = "main"
currentScreen = "generator"
// A form asking for a password wants a new one every time. A standalone
// visit keeps whatever was last generated, so reopening does not throw
// away a value you were about to copy.
if (generatorFeedsForm || !genValue) regenerate()
}
function closeGenerator() {
var toForm = generatorFeedsForm
currentScreen = generatorReturnScreen
generatorReturnScreen = "main"
// Land back on the field the trip was about, filled in or not.
if (toForm) Qt.callLater(function() { formPassField.forceActiveFocus() })
}
// The whole point of the round trip: put the value in the field the caller
// was on, and go back to it.
function useGeneratedPassword() {
if (!generatorFeedsForm || genBusy || !genValue) return
formPassword = genValue
// Show it. A password you cannot read is hard to trust, and it is going
// into a form you are still filling in rather than straight to the vault.
formPasswordRevealed = true
closeGenerator()
flashNotification("Generated password filled in")
}
// Generation is delegated to Bitwarden's own generator either way; the only
// question is how we reach it. `bw serve` answers in ~2ms against ~2.9s for
// a fresh `bw generate`, so the server is started on first use and the CLI
// stays as the fallback for when it cannot be.
function generatorOptionsSignature() {
return JSON.stringify(Model.normalizeGeneratorOptions(genOpts))
}
function regenerate() {
if (generateCliStopping) {
genBusy = true
genRegeneratePending = true
return
}
if (genBusy) {
genRegeneratePending = true
return
}
genBusy = true
genRegeneratePending = false
genRequestSignature = generatorOptionsSignature()
beginVaultRead("generator")
if (generateServeReady) {
requestGeneratedValue()
return
}
startGeneratorServe()
// Nothing to wait on if the server is already coming up -- onExited or the
// ready poll will drive the request.
if (!generateServeStarting) regenerateViaCli()
}
function regenerateViaCli() {
genBusy = true
genRegeneratePending = false
genRequestSignature = generatorOptionsSignature()
generateProc.command = Model.generateCommand(genOpts)
generateProc.running = true
}
// A locked server: no session in its environment, so it can generate and
// nothing else. See the comment on generateServeCommand in BitwardenModel.js
// for why that restriction is the whole point.
function generatorServeEnv() {
var env = {}
env[Model.sessionEnvVar()] = null
env[Model.noInteractionEnvVar()] = "true"
return env
}
// Nothing about an HTTP 200 proves the process that sent it is ours. Another
// account can bind the port first and answer /generate with passwords it
// already knows, and the panel would show one as freshly generated. There is
// no handshake to lean on -- `bw serve` prints no banner and offers no
// authentication -- so the evidence has to be that the port was silent before
// our own server took it. Anything already answering means the serve path is
// not available, and the CLI carries the feature instead.
function startGeneratorServe() {
if (generateServeReady || generateServeStarting || generateServeFailed) return
generateServeStarting = true
probeGeneratorPort()
}
// Every request to the generator port goes through a bounded child process
// rather than QML's XMLHttpRequest. XMLHttpRequest buffers responses in
// shared shell process memory before JavaScript can inspect or abort them,
// leaving the shell vulnerable to unbounded allocations from a rogue local
// port responder. The child process bounds both duration (--max-time) and
// payload volume (| head -c 65536) on the producer side, ensuring no more
// than 64KB ever enters the shell process.
//
// `done` is called with (exitCode, stdout, stderr).
property var generateServeRequestCallback: null
function generatorRequest(opts, done) {
if (generateServeRequestStopping || generateServeRequestProc.running) {
generateServeRequestPending = true
generateServeRequestPendingOptions = opts
generateServeRequestPendingCallback = done
return
}
generateServeRequestCallback = done
generateServeRequestProc.command = Model.generateServeRequestCommand(opts)
generateServeRequestProc.running = true
}
function resumePendingGeneratorRequest() {
if (!generateServeRequestPending) return false
var pendingOptions = generateServeRequestPendingOptions
var pendingCallback = generateServeRequestPendingCallback
generateServeRequestPending = false
generateServeRequestPendingOptions = null
generateServeRequestPendingCallback = null
Qt.callLater(function() {
if (root.opened && root.currentScreen === "generator")
root.generatorRequest(pendingOptions, pendingCallback)
})
return true
}
function probeGeneratorPort() {
generatorRequest(null, function(exitCode, stdout, stderr) {
if (Model.generatorProbeIsForeign(exitCode, stdout)) {
root.generateServeStarting = false
root.generateServeFailed = true
if (root.genBusy) root.regenerateViaCli()
return
}
// The screen can close while a probe is in flight, and starting a server
// for a screen nobody is looking at is the exposure this all avoids.
if (root.currentScreen !== "generator") {
root.generateServeStarting = false
return
}
generateServeProc.running = true
generateServePoll.attempts = 0
generateServePoll.restart()
})
}
function stopGeneratorServe() {
var cancelCliGeneration = genBusy && generateProc.running
generateServePoll.stop()
generateServeStarting = false
generateServeReady = false
// A deliberate shutdown is not the permanent bind failure, so the next
// visit is free to start a server again.
generateServeFailed = false
genBusy = false
genRegeneratePending = false
genRequestSignature = ""
generateServeRequestPending = false
generateServeRequestPendingOptions = null
generateServeRequestPendingCallback = null
if (generateServeRequestProc.running
&& !Model.isScrubCommand(generateServeRequestProc.command)) {
generateServeRequestCallback = null
generateServeRequestStopping = true
generateServeRequestProc.running = false
}
if (cancelCliGeneration) {
generateCliStopping = true
generateProc.running = false
}
if (generateServeProc.running) {
generateServeStopping = true
generateServeProc.running = false
}
}
// The server is up when it answers. Polling rather than trusting a fixed
// delay: bw takes a couple of seconds to bind, and the first generator open
// should not sit behind a guess.
function pollGeneratorServe() {
if (generateServeRequestProc.running) return
generatorRequest(root.genOpts, function(exitCode, stdout, stderr) {
if (exitCode !== 0) return
var value = Model.parseServeGenerated(stdout)
if (!value) return
root.generateServeStarting = false
root.generateServeReady = true
generateServePoll.stop()
root.onGenerated(value, 0)
})
}
function requestGeneratedValue() {
generatorRequest(root.genOpts, function(exitCode, stdout, stderr) {
var value = exitCode === 0 ? Model.parseServeGenerated(stdout) : ""
if (value) {
root.onGenerated(value, 0)
return
}
// The server went away mid-session, or stopped behaving like one; fall
// back and stop trusting it.
root.generateServeReady = false
root.regenerateViaCli()
})
}
function onGenerated(text, exitCode) {
if (vaultReadIsStale("generator")) {
genBusy = false
genRegeneratePending = false
return
}
if (genRegeneratePending || genRequestSignature !== generatorOptionsSignature()) {
genBusy = false
genRegeneratePending = false
regenerate()
return
}
genBusy = false
var v = String(text || "").trim()
if (exitCode !== 0 || !v) {
errorMessage = "Could not generate with these options"
return
}
genValue = v
}
// Every control funnels through here, so a change always regenerates --
// matching the extension's live behaviour -- and options stay normalised.
function setGenOpt(key, value) {
var next = {}
for (var k in genOpts) next[k] = genOpts[k]
next[key] = value
genOpts = Model.normalizeGeneratorOptions(next)
regenerate()
}
function copyGenerated() {
if (genBusy || !genValue) return
copyToClipboard(genValue, genOpts.type === "passphrase" ? "Passphrase" : "Password")
}
// -------------------------------------------------------------------------
// PIN Unlock
// -------------------------------------------------------------------------
function refreshPinConfigured() {
if (!keyringHasPinProc.running) keyringHasPinProc.running = true
}
function onPinConfiguredChecked(raw) {
pinConfigured = String(raw || "").trim() === "yes"
}
function beginPinSetup() {
pinSetupPin = ""
pinSetupConfirm = ""
pinSetupMaster = ""
pinError = ""
screenBeforeSettings = "main"
currentScreen = "pin"
Qt.callLater(function() { pinSetupPinField.forceActiveFocus() })
}
function abandonPinSetup() {
if (pinStoreProc.running) invalidateEpochOperation("pinStore")
pinBusy = false
pinSetupPin = ""
pinSetupConfirm = ""
pinSetupMaster = ""
}
// Encrypting needs the master password, and the vault does not keep it in
// memory once unlocked, so setting a PIN has to ask for it.
function submitPinSetup() {
if (pinBusy || pinStoreProc.running) return
var err = Model.validatePin(pinSetupPin, pinSetupConfirm)
if (err) { pinError = err; return }
if (!pinSetupMaster) { pinError = "Master password is required to encrypt the PIN"; return }
pinError = ""
pinBusy = true
beginEpochOperation("pinStore")
pinStoreProc.running = true
}
function onPinStored(exitCode) {
pinBusy = false
if (epochOperationIsStale("pinStore")) {
pinConfigured = false
pinSetupPin = ""
pinSetupConfirm = ""
pinSetupMaster = ""
requestPinCredentialClear()
return
}
if (exitCode !== 0) {
pinError = "Could not save the PIN. Is the OS keyring available?"
return
}
pinConfigured = true
pinSetupPin = ""
pinSetupConfirm = ""
pinSetupMaster = ""
pinAttempts = 0
writeSetting("pinUnlock", true, "bool")
flashNotification("PIN unlock enabled")
currentScreen = "settings"
}
function submitPinUnlock() {
if (!sshAuthSurfaceActive || !pinReady || isUnlocking || pinBusy) return
if (String(pinEntry || "").length < Model.pinMinLength()) {
pinError = "PIN must be at least " + Model.pinMinLength() + " digits"
return
}
pinError = ""
pinBusy = true
pinUnlockSubmitted = true
pinUnlockProc.command = Model.pinUnlockCommand()
pinUnlockProc.running = true
}
function onPinUnlockResult(exitCode, password) {
var accepting = pinUnlockSubmitted && sshAuthSurfaceActive && status === "locked"
pinUnlockSubmitted = false
pinBusy = false
if (!accepting) {
clearProcessCollectorSoon(pinUnlockProc)
return
}
var pw = String(password || "")
if (exitCode !== 0 || !pw) {
pinAttempts += 1
pinEntry = ""
if (pinAttempts >= pinMaxAttempts) {
// Refuse to keep serving guesses at the UI. The ciphertext goes too,
// so re-enabling requires the master password again.
clearPin()
pinError = "Too many incorrect PINs. PIN unlock has been removed -- use your master password."
} else {
pinError = "Incorrect PIN (" + pinAttempts + " of " + pinMaxAttempts + ")"
}
return
}
pinAttempts = 0
pendingUnlockFrom = "pin"
unlockVaultWithPassword(pw)
}
function clearPin() {
requestPinCredentialClear()
pinConfigured = false
pinEntry = ""
pinAttempts = 0
if (pinUnlock) writeSetting("pinUnlock", false, "bool")
}
function disablePinUnlock() {
clearPin()
pinError = ""
flashNotification("PIN unlock removed")
}
onPinUnlockChanged: {
if (pinUnlock) refreshPinConfigured()
else if (pinConfigured) clearPin()
}
// -------------------------------------------------------------------------
// Setup Wizard & Settings
// -------------------------------------------------------------------------
function checkDependencies() {
if (!depsCheckProc.running) depsCheckProc.running = true
}
function onDependenciesChecked(raw) {
dependencies = Model.parseDependencies(raw)
depsChecked = true
if (pinUnlock) refreshPinConfigured()
// Fingerprint availability comes from the same probe, so keep them in step.
for (var i = 0; i < dependencies.items.length; i++) {
if (dependencies.items[i].key === "fprintd") fingerprintAvailable = dependencies.items[i].ready
}
if (fingerprintAvailable && fingerprintUnlock) {
if (!keyringHasMasterProc.running) keyringHasMasterProc.running = true
} else {
fingerprintStored = false
}
// A missing required tool is not something to discover mid-task.
if (Model.missingRequired(dependencies).length > 0) setupWasGated = true
var next = Model.dependencyProbeOutcome(dependencies, setupDismissed, statusProbeStarted, setupWasGated)
if (next === "setup") {
currentScreen = "setup"
} else if (next === "probe") {
// Either the first look at the vault this session, or the one that
// follows an install landing. onStatusFinished puts up whichever screen
// the answer calls for, so setup gets left behind without being told to.
setupWasGated = false
refreshStatus()
}
}
readonly property var missingRequired: Model.missingRequired(dependencies)
readonly property var installablePackages: Model.missingPackages(dependencies)
// Whether anything on the setup screen is still waiting on the user. Covers
// the setup rows too, so a fingerprint enrolment running in its own terminal
// is watched for the same way an install is.
readonly property bool setupActionsPending: {
var rows = Model.applicableDependencies(dependencies)
for (var i = 0; i < rows.length; i++) {
if (!rows[i].ready) return true
}
return false
}
function installMissing() {
var pkgs = Model.missingPackages(dependencies)
var cmd = Model.installPackagesCommand(pkgs,
pkgs.length === 1 ? "Bitwarden CLI" : "Bitwarden plugin dependencies")
if (!cmd) return
Quickshell.execDetached(cmd)
flashNotification("Installing -- this screen updates itself")
}
function installOne(dep) {
if (!dep) return
// Omarchy's setup command owns its own rows; `pkg add` on one of those
// would install a package and leave the row exactly as red as it was.
if (dep.setup) {
runFingerprintSetup()
return
}
var cmd = Model.installPackagesCommand([dep.pkg], dep.label)
if (!cmd) return
Quickshell.execDetached(cmd)
flashNotification("Installing " + dep.pkg + " -- this screen updates itself")
}
// Stepping past setup. The gate is what was holding the first status probe
// back, so opening it has to release that probe as well -- otherwise the
// panel would sit on a login screen it never actually asked `bw` about.
function dismissSetup() {
setupDismissed = true
currentScreen = status === "unlocked" ? "main"
: (status === "locked" ? "locked" : "login")
if (!statusProbeStarted) refreshStatus()
}
function runFingerprintSetup() {
Quickshell.execDetached(Model.fingerprintSetupCommand())
flashNotification("Fingerprint setup opened -- this screen updates itself")
}
// A setting whose dependency is missing is inert; the cursor may sit on it,
// but changing it would silently do nothing.
function settingBlocked(entry) {
if (!entry || !entry.requires) return false
for (var i = 0; i < dependencies.items.length; i++) {
if (dependencies.items[i].key === entry.requires) return !dependencies.items[i].ready
}
return false
}
// Group headings are rows in the list but not controls, so the cursor steps
// over them rather than stopping on one and doing nothing when activated.
function moveSettingsCursor(delta) {
var n = settingsEntries.length
if (n === 0) return
var step = delta < 0 ? -1 : 1
var i = settingsIndex + delta
while (i >= 0 && i < n && settingsEntries[i] && settingsEntries[i].kind === "group") i += step
// A heading at the far end leaves nowhere further to go in that direction;
// the cursor stays where it was rather than landing on the heading.
if (i < 0 || i >= n) return
settingsIndex = i
}
function firstSettingIndex() {
for (var i = 0; i < settingsEntries.length; i++) {
if (settingsEntries[i] && settingsEntries[i].kind === "setting") return i
}
return 0
}
// Left/right nudge a value: numbers by their step, switches off and on.
function adjustSetting(direction) {
var e = settingsEntries[settingsIndex]
if (!e || settingBlocked(e)) return
if (e.type === "int") {
var cur = Number(settingValue(e))
var step = e.step || 1
var next = Math.max(e.min || 0, Math.min(e.max || 100, cur + direction * step))
if (next !== cur) writeSetting(e.key, next, "int")
return
}
if (e.type === "bool") {
var want = direction > 0
if (Boolean(settingValue(e)) !== want) activateSettingRow()
}
}
// The lane every vertical scrollbar in this panel gets to itself.
//
// These bars are overlays: left alone they draw on top of whatever occupies
// the right edge of the view, which across these screens is toggles, number
// fields, copy buttons and the ends of elided text. Every scrolling view
// subtracts this from its content width, so the bar has somewhere to be and
// the right-hand edges of all of them line up.
//
// Measured from a real scrollbar rather than guessed at, so a theme with a
// wider one does not put it back over the controls. One bar stands in for
// all of them because they are the same control with the same style; the
// floor covers both a null reference and the frames before it has an
// implicit width of its own.
readonly property real scrollGutter:
Math.max(settingsScrollBar ? settingsScrollBar.implicitWidth : 0, Style.space(10))
// Which section the view is currently inside, named by the pinned indicator.
// Held rather than derived, because it depends on delegate geometry the
// Repeater only knows after layout, and a binding cannot read that without
// fighting it.
property var settingsStickyEntry: null
// The settings view's two geometry questions, in one place. Everything else
// that needs them goes through these rather than reaching into the Flickable
// and the Repeater by id from across the file.
function settingsViewportTop() { return settingsFlick ? settingsFlick.contentY : 0 }
function settingsRepeaterItem(i) {
return settingsRepeater ? settingsRepeater.itemAt(i) : null
}
// The section the view is currently inside: the last heading at or above the
// top of the viewport, while any part of its section is still on screen.
//
// Both halves matter. Without the first the bar sits empty until the user
// has scrolled, which is the one position everybody starts from. Without the
// second the last group stays named through the maintenance and danger-zone
// rows below it, which belong to no section and would leave the bar
// describing somewhere the user had already scrolled past.
//
// Drawing the heading twice is prevented at the other end: the in-list
// heading of whichever section this names is drawn transparent, so it keeps
// its place in the layout without appearing alongside its own copy.
function updateSettingsSticky() {
var entries = settingsEntries
var top = settingsViewportTop()
var found = null
for (var i = 0; i < entries.length; i++) {
if (!entries[i] || entries[i].kind !== "group") continue
var row = settingsRepeaterItem(i)
if (!row) continue
// Still below the top edge: the section before this one is the one the
// view is in.
if (row.y > top + 1) break
if (top < settingsSectionEnd(i)) found = entries[i]
}
settingsStickyEntry = found
}
// Where the section beginning at `index` stops: the next heading, or for the
// last one, the bottom of the final row before the trailing action blocks.
function settingsSectionEnd(index) {
var entries = settingsEntries
for (var i = index + 1; i < entries.length; i++) {
if (!entries[i] || entries[i].kind !== "group") continue
var next = settingsRepeaterItem(i)
if (next) return next.y
}
for (var j = entries.length - 1; j > index; j--) {
var last = settingsRepeaterItem(j)
if (last) return last.y + last.height
}
var self = settingsRepeaterItem(index)
return self ? self.y + self.height : 0
}
function activateSettingRow() {
var e = settingsEntries[settingsIndex]
if (!e || settingBlocked(e)) return
// These two open a form rather than flipping a value.
if (e.action === "pin") {
if (pinConfigured) disablePinUnlock()
else beginPinSetup()
return
}
if (e.action === "fingerprint") {
if (fingerprintStored) forgetFingerprintUnlock()
else beginFingerprintSetup()
return
}
if (e.type === "bool") writeSetting(e.key, !settingValue(e), "bool")
}
function openSettings() {
closeFilterGroup()
if (currentScreen !== "settings") screenBeforeSettings = currentScreen
settingsFlash = ""
settingsIndex = firstSettingIndex()
uwsmFlash = ""
uwsmConfirmPending = false
checkDependencies()
inspectUwsmFragment()
currentScreen = "settings"
Qt.callLater(updateSettingsSticky)
}
function closeSettings() {
currentScreen = (screenBeforeSettings === "settings" ? "main" : screenBeforeSettings)
}
// Persisted via `omarchy bar set`, which owns shell.json. The shell reloads
// on write, so setting() reflects the new value without us caching it.
function writeSetting(key, value, type) {
settingWriteProc.command = Model.settingWriteCommand(key, value, type)
settingWriteProc.running = true
settingsFlash = "Saved"
settingsFlashTimer.restart()
}
// The remembered two-step method is not a preference anybody set, so it is
// written without the settings screen's "Saved" flash -- it is a note the
// login leaves for the next one, and it has no row to flash next to.
function writeSettingQuietly(key, value, type) {
settingWriteProc.command = Model.settingWriteCommand(key, value, type)
settingWriteProc.running = true
}
function rememberTwoFactorMethod(method) {
if (!Model.isTwoFactorMethod(method)) return
if (method === rememberedTwoFactorMethod) return
var next = Model.rememberTwoFactorMethodIn(twoFactorMethodStore, loginEmail, method)
if (next) writeSettingQuietly("twoFactorMethods", next, "json")
}
function forgetTwoFactorMethod() {
if (rememberedTwoFactorMethod < 0) return
var next = Model.forgetTwoFactorMethodIn(twoFactorMethodStore, loginEmail)
if (next) writeSettingQuietly("twoFactorMethods", next, "json")
}
// Read back through the same properties the plugin actually runs on, so the
// settings screen can never show a different value than the one in effect.
// (setting() alone would miss the manifest defaults for unset keys.)
function settingValue(entry) {
if (!entry) return 0
switch (entry.key) {
case "autoLockMinutes": return autoLockMinutes
case "clearClipboardSec": return clearClipboardSec
case "lockOnScreenLock": return lockOnScreenLock
case "lockOnSuspend": return lockOnSuspend
case "autoCopyTotpSec": return autoCopyTotpSec
case "closeOnCopy": return closeOnCopy
case "suggestOnOpen": return suggestOnOpen
case "rememberSession": return rememberSession
case "fingerprintUnlock": return fingerprintUnlock && fingerprintStored
// The toggle reflects a PIN actually being set, not just the flag.
case "pinUnlock": return pinUnlock && pinConfigured
case "sshAgentEnabled": return sshAgentEnabled
case "sshAgentUnlockOnDemand": return sshAgentUnlockOnDemand
case "sshAgentApprovalPopup": return sshAgentApprovalPopup
case "sshAgentApprovalWindowSec": return sshAgentApprovalWindowSec
}
return entry.type === "bool" ? Model.boolSetting(entry.key, setting(entry.key, entry.defaultValue)) : Number(setting(entry.key, 0))
}
function refreshFingerprintAvailability() {
checkDependencies()
}
function onFingerprintStoredChecked(raw) {
fingerprintStored = String(raw || "").trim() === "yes"
if (sshAuthSurfaceActive && status === "locked") startFingerprintUnlock()
}
function startFingerprintUnlock() {
if (!fingerprintReady || status !== "locked" || isUnlocking) return
if (fingerprintScanning || fingerprintPam.active) return
if (!userName) {
fingerprintMessage = "Cannot determine current user for fingerprint verification"
return
}
errorMessage = ""
fingerprintAuthorized = false
fingerprintScanning = true
fingerprintMessage = "󰈷 Touch the fingerprint reader..."
if (!fingerprintPam.start()) {
fingerprintScanning = false
fingerprintMessage = "Could not start fingerprint verification"
}
}
function cancelFingerprintUnlock() {
fingerprintScanning = false
fingerprintAuthorized = false
if (fingerprintPam.active) fingerprintPam.abort()
}
function onFingerprintResult(result) {
var accepting = fingerprintScanning && sshAuthSurfaceActive && status === "locked"
fingerprintScanning = false
if (!accepting) return
if (result === PamResult.Success) {
fingerprintAuthorized = true
fingerprintMessage = "󰈷 Fingerprint verified, unlocking..."
if (!keyringLookupMasterProc.running) {
keyringLookupMasterProc.command = Model.keyringLookupMasterPasswordCommand()
keyringLookupMasterProc.running = true
}
} else if (result === PamResult.MaxTries) {
fingerprintMessage = "Too many fingerprint attempts. Use your master password."
} else {
fingerprintMessage = "Fingerprint not recognised. Try again or use your master password."
}
}
// Only ever called after PamResult.Success.
function onFingerprintPasswordRetrieved(raw) {
if (!fingerprintAuthorized || !sshAuthSurfaceActive || status !== "locked") {
fingerprintAuthorized = false
clearProcessCollectorSoon(keyringLookupMasterProc)
return
}
fingerprintAuthorized = false
// The keyring command removes secret-tool's output newline. Do not trim
// here: spaces at either end can be part of the actual master password.
var pw = String(raw || "")
if (!pw) {
fingerprintStored = false
fingerprintMessage = "No stored master password. Unlock with your password once to enable this."
return
}
pendingUnlockFrom = "fingerprint"
unlockVaultWithPassword(pw)
}
// Enrolling asks for the master password up front, the same way setting a
// PIN does, rather than silently capturing it on some later unlock.
function beginFingerprintSetup() {
fpSetupMaster = ""
fpError = ""
currentScreen = "fingerprint"
Qt.callLater(function() { fpMasterField.forceActiveFocus() })
}
function abandonFingerprintSetup() {
var active = fpSetupActive
if (active && keyringStoreMasterProc.running) invalidateEpochOperation("masterStore")
fpSetupActive = false
fpBusy = false
fpSetupMaster = ""
if (active) masterToStore = ""
}
function submitFingerprintSetup() {
if (fpBusy || keyringStoreMasterProc.running) return
if (!fpSetupMaster) {
fpError = "Master password is required to enable fingerprint unlock"
return
}
fpError = ""
fpBusy = true
fpSetupActive = true
masterToStore = fpSetupMaster
beginEpochOperation("masterStore")
keyringStoreMasterProc.running = true
}
function onMasterPasswordStored(exitCode) {
masterToStore = ""
pendingUnlockPassword = ""
if (epochOperationIsStale("masterStore")) {
fpSetupActive = false
fpBusy = false
fpSetupMaster = ""
fingerprintStored = false
requestMasterCredentialClear()
return
}
fingerprintStored = (exitCode === 0)
if (fpSetupActive) {
fpSetupActive = false
fpBusy = false
fpSetupMaster = ""
if (exitCode !== 0) {
fpError = "Could not save the master password. Is the OS keyring available?"
return
}
writeSetting("fingerprintUnlock", true, "bool")
flashNotification("Fingerprint unlock enabled")
currentScreen = "settings"
return
}
if (exitCode !== 0) {
errorMessage = "Could not save master password to the OS keyring, so fingerprint unlock is unavailable."
}
}
function forgetFingerprintUnlock() {
requestMasterCredentialClear()
fingerprintStored = false
cancelFingerprintUnlock()
fingerprintMessage = ""
flashNotification("Fingerprint unlock forgotten")
}
onFingerprintUnlockChanged: {
if (!fingerprintUnlock) {
cancelFingerprintUnlock()
fingerprintMessage = ""
// Not `if (fingerprintStored)`. That flag is false whenever the reader
// or fprintd is missing, which says nothing about whether the master
// password is still sitting in the keyring -- and turning the feature
// off is precisely when it must not be.
forgetFingerprintUnlock()
} else {
refreshFingerprintAvailability()
}
}
// -------------------------------------------------------------------------
// Vault Unlock & Lock
// -------------------------------------------------------------------------
function unlockVault() {
pendingUnlockFrom = ""
unlockVaultWithPassword(masterPassword)
}
function unlockVaultWithPassword(pass) {
var p = String(pass === undefined || pass === null ? "" : pass)
if (!p) {
errorMessage = "Master password required"
return
}
cancelFingerprintUnlock()
errorMessage = ""
isUnlocking = true
// Kept only until the unlock result is known; cleared on both paths below.
// The short-lived FIFO writer reads it as BW_PASSWORD. unlockProc was
// already bootstrapping while the user typed and never receives it.
pendingUnlockPassword = p
prepareUnlock()
unlockSubmitted = true
writeAuthPassword("unlock", p)
}
function onUnlockOutput(stdoutText, stderrText, exitCode) {
isUnlocking = false
var out = String(stdoutText || "").trim()
var err = String(stderrText || "").trim()
if (exitCode === 0 && out) {
onUnlockSuccess(out)
} else {
pendingUnlockPassword = ""
// A stored secret the vault no longer accepts is useless: drop it rather
// than fail on every open, and say which one went stale.
if (pendingUnlockFrom === "fingerprint") {
pendingUnlockFrom = ""
requestMasterCredentialClear()
fingerprintStored = false
fingerprintMessage = "Stored password no longer valid. Unlock with your master password to re-enable fingerprint unlock."
errorMessage = ""
focusAppropriateField()
Qt.callLater(prepareUnlock)
return
}
if (pendingUnlockFrom === "pin") {
pendingUnlockFrom = ""
clearPin()
pinError = "Your master password changed, so the PIN no longer works. Unlock with your password and set a new PIN."
errorMessage = ""
focusAppropriateField()
Qt.callLater(prepareUnlock)
return
}
if (err.indexOf("not logged in") !== -1) {
status = "unauthenticated"
currentScreen = "login"
errorMessage = "You are not logged in. Please log in below."
} else {
errorMessage = err || "Unlock failed: invalid master password"
Qt.callLater(prepareUnlock)
}
}
}
function onUnlockSuccess(rawSession) {
var s = Model.extractSessionToken(rawSession)
masterPassword = ""
loginPassword = ""
loginClientId = ""
loginClientSecret = ""
login2faCode = ""
show2faField = false
loginDeviceVerification = false
loginAttemptHadCode = false
show2faMethodPicker = false
login2faMethodConfirmed = false
login2faMethod = rememberedTwoFactorMethod
loginAttemptMethod = -1
showDeviceCodeField = false
loginDeviceCode = ""
deviceVerificationAttempt = false
deviceVerificationPending = false
secondFactorStartedAt = 0
loginPasswordRetryUsed = false
initialSyncAttempted = false
syncLoginFieldsToState()
isUnlocking = false
unlockSubmitted = false
if (!s) {
errorMessage = "Unlock did not return a session key"
return
}
session = s
vaultEpoch += 1
status = "unlocked"
currentScreen = "main"
flashNotification("Vault unlocked successfully!")
storeCurrentSession()
// Opting in stores the master password so a finger can stand in for it later.
// Keep an existing enrolment current after a master password change. It no
// longer creates one -- that is what the setup form is for.
if (fingerprintUnlock && fingerprintAvailable && fingerprintStored
&& pendingUnlockPassword && pendingUnlockFrom === ""
&& !keyringStoreMasterProc.running) {
masterToStore = pendingUnlockPassword
beginEpochOperation("masterStore")
keyringStoreMasterProc.running = true
} else {
pendingUnlockPassword = ""
}
pendingUnlockFrom = ""
pinEntry = ""
pinAttempts = 0
pinError = ""
fingerprintMessage = ""
beginInitialVaultLoad(true, false)
resetAutoLockTimer()
focusAppropriateField()
}
function lockVault() {
closeFilterGroup()
cancelAuthPrewarm()
clearClipboard()
// Before bw lock is launched, so the companion's deny transition is not
// sequenced behind it. The panel's own lock never waits on the answer.
applySshAgentLifecycle("lock")
if (session) {
lockProc.command = Model.lockCommand()
lockProc.running = true
}
// Not `if (rememberSession)`. The setting says whether to write a token,
// not whether one is there: turning it off after a session was remembered
// used to mean the lock skipped the erase and left the token behind.
// Clearing an entry that was never written is a no-op nobody reads.
requestSessionCredentialClear()
dropVaultState()
status = "locked"
currentScreen = "locked"
fingerprintMessage = ""
flashNotification("Vault locked")
focusAppropriateField()
if (sshAuthSurfaceActive) startFingerprintUnlock()
}
function vaultStatePresent() {
return !!session || status === "unlocked" || items.length > 0
|| organizations.length > 0 || folders.length > 0 || detailItem !== null
|| sends.length > 0 || itemPayloadJson !== "" || sendPayloadJson !== ""
}
// One local purge for every way an open vault stops being usable. Keeping
// this separate from the `bw lock` and keyring side effects lets a status
// transition fail closed without pretending that a remote/local CLI error
// was a successful Bitwarden lock command.
function dropVaultState() {
initialSyncAttempted = false
pinUnlockSubmitted = false
cancelFingerprintUnlock()
cancelAttachmentDownloads()
session = ""
vaultEpoch += 1
readEpochs = ({})
masterPassword = ""
itemsLoadedAt = 0
orgsLoadedAt = 0
foldersLoadedAt = 0
items = []
filteredItems = []
organizations = []
folders = []
selectedOrg = "all"
selectedFolder = "all"
openFilterGroup = ""
searchQuery = ""
selectedCategory = "all"
selectedIndex = 0
detailItem = null
revealedFields = ({})
attachmentSaved = ({})
formIsEditing = false
formItemId = ""
formTypeCode = 1
clearTypeFields()
formName = ""
formUsername = ""
formUri = ""
formNotes = ""
formFavorite = false
formOrgId = ""
formFolderId = ""
formPicker = ""
formCollections = []
formCollectionIds = []
formCollectionsLoading = false
newFolderName = ""
creatingFolder = false
totpFollowupActive = false
isLoading = false
isUnlocking = false
isSyncing = false
metadataLoadPending = false
metadataForceRefresh = false
statusRefreshAfterItems = false
syncReloadPending = false
sendsLoading = false
sendBusy = false
genBusy = false
pendingUnlockPassword = ""
sessionStorePending = false
dropVaultSecrets()
}
// A locked vault means the panel is holding nothing out of it, and nothing
// that would open it again. detailPassword and liveTotp were always dropped
// here; the rest were not, and each of them is the same kind of thing -- a
// generated password nobody copied, an item or Send form left mid-compose,
// the payload JSON on its way to bw, the master password typed into whichever
// setup form was open. The vault relocks after fifteen idle minutes and the
// shell process lives for the whole desktop session, so a property that
// survives a lock survives everything.
function dropVaultSecrets() {
detailPassword = ""
liveTotp = ""
totpRequestItemId = ""
totpQueuedItemId = ""
totpQueuedEpoch = -1
totpRestartPending = false
totpCopyItemId = ""
passwordCopyItemId = ""
totpFollowupItem = null
totpFollowupCode = ""
genValue = ""
formPassword = ""
formTotp = ""
itemPayloadJson = ""
sends = []
sendPayloadJson = ""
sendFormText = ""
sendFormPassword = ""
loginPassword = ""
login2faCode = ""
show2faField = false
loginDeviceVerification = false
loginAttemptHadCode = false
show2faMethodPicker = false
login2faMethodConfirmed = false
login2faMethod = rememberedTwoFactorMethod
loginAttemptMethod = -1
showDeviceCodeField = false
loginDeviceCode = ""
deviceVerificationAttempt = false
deviceVerificationPending = false
secondFactorStartedAt = 0
loginPasswordRetryUsed = false
loginClientId = ""
loginClientSecret = ""
syncLoginFieldsToState()
pinEntry = ""
pinSetupPin = ""
pinSetupConfirm = ""
pinSetupMaster = ""
fpSetupMaster = ""
masterToStore = ""
pendingAssociationsJson = ""
scrubSecretBuffers()
}
// Emptying those properties leaves the values they were copied out of still
// sitting in the collectors that read them, which is the same residue one
// step upstream. See the collector-scrubbing note in BitwardenModel.js for
// why running a command that prints nothing is the way to clear one.
//
// Built on demand rather than held as a property: these ids are declared
// below this point, and a list bound at creation time would be a list of
// undefineds.
function secretProcesses() {
return [
statusProc, sessionHandoffProc, keyringLookupProc, pinUnlockProc, keyringLookupMasterProc,
loginProc, unlockProc, listProc, listOrgsProc, listFoldersProc, orgCollectionsProc,
getItemProc, getTotpProc, generateProc, listSendsProc, createSendProc,
copyPasswordProc,
createItemProc, editItemProc, deleteItemProc, createFolderProc, attachmentProc,
associationsReadProc, generateServeRequestProc
]
}
function scrubSecretBuffers() {
scrubPending = secretProcesses()
scrubStep()
if (scrubPending.length) scrubRetry.restart()
}
// A process still running when the vault locked cannot be scrubbed yet --
// its buffer is in the middle of being written, and taking its command away
// would abandon a read someone is still waiting on. It stays in the queue
// and the retry comes back for it.
function scrubStep() {
var pass = Model.scrubPass(scrubPending)
for (var i = 0; i < pass.start.length; i++) {
pass.start[i].command = Model.scrubCommand()
pass.start[i].running = true
}
scrubPending = pass.waiting
}
// Complete a scrub before its handler can reuse the same Process. What
// arrives from a scrub is an empty string and exit status zero, which reads
// as a successful login, empty vault or saved item unless every handler asks
// here first.
function finishScrubRun(proc) {
if (!Model.isScrubCommand(proc.command)) return false
scrubPending = Model.finishScrub(scrubPending, proc)
if (!scrubPending.length) scrubRetry.stop()
return true
}
function clearProcessCollectorSoon(proc) {
Qt.callLater(function() {
if (proc.running) return
// Deferred by a callLater, so a submit can arrive between the schedule
// and the run. Taking the process here would make that submit wait on
// the scrub instead of on its own login.
if (proc === loginProc
&& (loginSubmitAfterPrewarmStop || loginPrepareAfterPrewarmStop
|| deviceVerificationPending || loginSubmitted)) return
proc.command = Model.scrubCommand()
proc.running = true
})
}
// -------------------------------------------------------------------------
// Vault Data Operations
// -------------------------------------------------------------------------
// Stamped on a reader as it starts, and checked again where its answer
// arrives. A `bw` already in flight when the vault locks cannot be called
// back -- it is past the point where the session mattered -- so the only
// place left to refuse its answer is the completion handler. See the Vault
// generation section of BitwardenModel.js for what that answer costs when
// nobody refuses it.
function beginEpochOperation(name) {
readEpochs[name] = vaultEpoch
}
function epochOperationIsStale(name) {
return Number(readEpochs[name]) !== Number(vaultEpoch)
}
function invalidateEpochOperation(name) {
readEpochs[name] = vaultEpoch - 1
}
function beginVaultRead(name) {
beginEpochOperation(name)
}
function vaultReadIsStale(name) {
return epochOperationIsStale(name) || !session
}
// The first post-authentication process is always the item list. Organization
// and folder metadata each need another bw bootstrap, so they are scheduled
// only after items have reached the model and had time to paint.
function beginInitialVaultLoad(showSpinner, forceMetadata) {
metadataLoadPending = true
metadataForceRefresh = forceMetadata === true
loadItems(showSpinner)
}
// Open-time load: skip the CLI entirely when the in-memory vault is fresh.
// Stale-while-revalidate. `bw list items` is a CLI bootstrap plus a full
// vault decrypt, so blocking the panel on it means a spinner on every open
// once the cache ages out. Show what we already have immediately, refresh
// behind it, and swap the list in when it lands. The spinner is only for
// the case where there is genuinely nothing to show yet.
function ensureItemsFresh() {
var haveItems = items.length > 0
var stale = (Date.now() - itemsLoadedAt) >= itemsFreshMs
if (haveItems) {
if (activeWindowData) handleActiveWindowDetected(activeWindowData)
else rebuildFilter()
if (!stale) return
}
beginInitialVaultLoad(!haveItems, false)
}
// `showSpinner` defaults to true, so existing callers are unchanged; a
// background revalidation passes false and refreshes without the UI moving.
function loadItems(showSpinner) {
if (!session) return
if (showSpinner !== false) isLoading = true
beginVaultRead("items")
listReadMode = Model.vaultListMode(dependencies)
if (listReadMode === "blocked") {
isLoading = false
if (!vaultReadIsStale("items")) errorMessage = Model.vaultListBlockedMessage(dependencies)
return
}
startVaultListRead(false)
}
// The one place the item read is launched, so the agent branch and its
// retry-without-it cannot drift apart. `retrying` is the second attempt
// after a fan-out read failed; it never carries the branch.
function startVaultListRead(retrying) {
var useAgent = !retrying && sshAgentGateOpen && Model.isValidLoadId(sshAgentNextLoadId)
if (useAgent) {
sshAgentEpoch += 1
sshAgentLoadId = sshAgentNextLoadId
sshAgentNextLoadId = ""
sshAgentLoadActive = true
sshAgentLoadedForVaultEpoch = root.vaultEpoch
if (sshAgentProc.stdinEnabled) {
sshAgentProc.write(Model.sshAgentLoadBeginLine(sshAgentEpoch, sshAgentLoadId))
}
}
listAgentBranchActive = useAgent
listProc.environment = root.vaultListEnv(useAgent ? sshAgentLoadId : "")
listProc.command = Model.sanitizedListCommand({ agentBranch: useAgent })
listProc.running = true
}
// The nonce reaches `jq` through the environment rather than argv, because
// /proc/<pid>/cmdline is world-readable and the nonce's whole purpose is
// being unguessable by another process running as this user.
function vaultListEnv(loadId) {
var env = root.bwEnv()
env[Model.loadIdEnvVar()] = loadId !== "" ? loadId : null
return env
}
function onListFinished(rawJson) {
isLoading = false
if (vaultReadIsStale("items")) return
sshCapability = Model.inspectSanitizedVault(rawJson)
items = Model.parseSanitizedItems(rawJson)
itemsLoadedAt = Date.now()
refreshDerivedFromItems()
if (syncReloadPending) {
syncReloadPending = false
isSyncing = false
flashNotification("Vault synced with Bitwarden")
}
if (metadataLoadPending) deferredMetadataTimer.restart()
// The first read of a session usually beats the helper's handshake, so it
// carries no keys. Now that it has landed, check whether one is owed.
maybeStartupLoad()
}
function onListProcessExited(exitCode, rawJson, stderrText) {
if (finishScrubRun(listProc)) return
var hadAgentBranch = listAgentBranchActive
listAgentBranchActive = false
endSshAgentLoad(exitCode === 0)
if (exitCode === 0) {
listRetriedWithoutAgent = false
onListFinished(rawJson)
return
}
// The optional feature is never allowed to cost the user their item list.
// One retry, without the branch, before anything is reported as an error.
if (hadAgentBranch && !listRetriedWithoutAgent && !vaultReadIsStale("items")) {
listRetriedWithoutAgent = true
beginVaultRead("items")
startVaultListRead(true)
return
}
listRetriedWithoutAgent = false
isLoading = false
isSyncing = false
syncReloadPending = false
metadataLoadPending = false
metadataForceRefresh = false
if (statusRefreshAfterItems) {
statusRefreshAfterItems = false
}
if (!vaultReadIsStale("items")) {
errorMessage = Model.vaultListFailureMessage(stderrText, dependencies, listReadMode)
}
}
// Each of these is its own `bw` invocation, and organizations and folders
// change rarely -- new ones arrive through this panel, which invalidates
// them explicitly. `force` is for exactly that case.
function loadOrganizations(force) {
if (!session) return
if (!force && organizations.length > 0 && (Date.now() - orgsLoadedAt) < metaFreshMs) return
beginVaultRead("organizations")
listOrgsProc.command = Model.listOrganizationsCommand()
listOrgsProc.running = true
}
function onListOrgsFinished(rawJson) {
if (vaultReadIsStale("organizations")) return
organizations = Model.parseOrganizations(rawJson)
orgsLoadedAt = Date.now()
}
function loadFolders(force) {
if (!session) return
if (!force && folders.length > 0 && (Date.now() - foldersLoadedAt) < metaFreshMs) return
beginVaultRead("folders")
listFoldersProc.command = Model.listFoldersCommand()
listFoldersProc.running = true
}
function onListFoldersFinished(rawJson) {
if (vaultReadIsStale("folders")) return
folders = Model.parseFolders(rawJson)
foldersLoadedAt = Date.now()
}
function selectFolder(folderId) {
selectedFolder = folderId
selectedIndex = 0
openFilterGroup = ""
rebuildFilter()
}
function toggleFilterGroup(group) {
if (openFilterGroup === group) {
openFilterGroup = ""
return
}
openFilterGroup = group
// Start on whichever option is currently active, so Enter is a no-op
// rather than a surprise.
var opts = filterOptions(group)
filterOptionIndex = 0
for (var i = 0; i < opts.length; i++) {
if (opts[i].active) { filterOptionIndex = i; break }
}
}
// Any action that is not part of the drawer closes it, so it never lingers
// over the results the user just filtered down to.
function closeFilterGroup() {
if (openFilterGroup !== "") openFilterGroup = ""
}
function moveFilterCursor(delta) {
var n = currentFilterOptions.length
if (n === 0) return
filterOptionIndex = Math.max(0, Math.min(n - 1, filterOptionIndex + delta))
}
function activateFilterOption() {
var opts = currentFilterOptions
if (filterOptionIndex < 0 || filterOptionIndex >= opts.length) return
applyFilterOption(openFilterGroup, opts[filterOptionIndex].id)
}
// Labels for the collapsed buttons, so the current filter is readable
// without opening anything.
function folderFilterLabel() {
if (selectedFolder === "all") return "All"
if (selectedFolder === "none") return "Unfiled"
return Model.folderName(folders, selectedFolder) || "Folder"
}
function organizationFilterLabel() {
if (selectedOrg === "all") return "All"
if (selectedOrg === "personal") return "Personal"
for (var i = 0; i < organizations.length; i++) {
if (organizations[i].id === selectedOrg) return organizations[i].name
}
return "Vault"
}
function typeFilterLabel() {
for (var i = 0; i < categories.length; i++) {
if (categories[i].id === selectedCategory) return categories[i].label
}
return "All"
}
// Option rows for whichever group is open, in one shape so the three lists
// render identically.
function filterOptions(group) {
var out = []
var i
if (group === "folders") {
out.push({ id: "all", label: "All Folders", icon: "󰉋", active: selectedFolder === "all" })
out.push({ id: "none", label: "No Folder", icon: "󰉖", active: selectedFolder === "none" })
for (i = 0; i < folders.length; i++) {
out.push({ id: folders[i].id, label: folders[i].name, icon: "󰉋", active: selectedFolder === folders[i].id })
}
} else if (group === "organizations") {
out.push({ id: "all", label: "All Organizations", icon: "󰦑", active: selectedOrg === "all" })
out.push({ id: "personal", label: "My Vault", icon: "", active: selectedOrg === "personal" })
for (i = 0; i < organizations.length; i++) {
out.push({ id: organizations[i].id, label: organizations[i].name, icon: "󰓹", active: selectedOrg === organizations[i].id })
}
} else if (group === "types") {
for (i = 0; i < visibleCategories.length; i++) {
out.push({ id: visibleCategories[i].id, label: visibleCategories[i].label, icon: visibleCategories[i].icon, active: selectedCategory === visibleCategories[i].id })
}
}
return out
}
function applyFilterOption(group, id) {
if (group === "folders") selectFolder(id)
else if (group === "organizations") { selectOrganization(id); openFilterGroup = "" }
else if (group === "types") { selectCategory(id); openFilterGroup = "" }
}
function toggleFormPicker(which) {
formPicker = (formPicker === which) ? "" : which
}
// What Escape does, wherever it is pressed. Kept here rather than inline in
// the key handler because it has two callers: PanelKeyCatcher's
// closeRequested, and the shortcut interceptor -- the catcher goes `blocked`
// on every screen with a text field, which used to take Escape down with it.
//
// Innermost thing first: a drawer or picker closes before the screen it is
// on, and a screen goes back before the panel closes.
function handleEscape() {
// Ahead of every other screen: a signing request is a question with a
// client blocked on the answer, so dismissing it has to mean "no" rather
// than "later".
if (currentScreen === "sshApproval" || sshUnlockRequest) {
denySshRequest()
return
}
if (openFilterGroup !== "") {
closeFilterGroup()
return
}
if (currentScreen === "edit" && formPicker !== "") {
formPicker = ""
return
}
if (currentScreen === "sends") {
if (sendMode === "create") {
sendError = ""
sendMode = "list"
// Leaving the composer does not change the screen, so nothing else
// takes focus off its (now hidden) name field.
restoreScreenFocus()
} else {
currentScreen = "main"
}
} else if (currentScreen === "generator") {
// Back to the item form when that is where this came from, leaving
// the password field as it was.
closeGenerator()
} else if (currentScreen === "fingerprint") {
fpError = ""
currentScreen = "settings"
} else if (currentScreen === "pin") {
pinError = ""
currentScreen = "settings"
} else if (currentScreen === "settings") {
closeSettings()
} else if (currentScreen === "setup") {
dismissSetup()
} else if (currentScreen === "edit") {
// Editing is abandoned, not saved -- the form is scratch space until
// Save, and Escape is how you throw it away. Back where the form was
// opened from, which is what the form's own Cancel button does.
currentScreen = formIsEditing ? "detail" : "main"
} else if (currentScreen === "detail") {
currentScreen = "main"
} else {
close()
}
}
// Qt does not clear active focus when an item is hidden, so leaving a screen
// whose field had focus leaves that field owning the keyboard from behind
// whatever replaced it -- which is how Escape on the item form reached the
// search box and closed the panel. Re-home focus whenever the screen
// changes, and the stale owner goes with it.
onCurrentScreenChanged: {
// The server lives as long as the screen that needs it and no longer. A
// loopback port has no authentication and every account on the machine can
// reach it, and `bw serve` answers /status with the account email and user
// id whether the vault is locked or not. Holding that open for hours to
// save a second on a screen visited for a few is the wrong trade.
if (currentScreen !== "generator") stopGeneratorServe()
// Both setup forms ask for the master password, and both used to keep it
// for the rest of the shell's life: Cancel and Escape only reset the error
// line. Leaving the form is the answer either way, so the clearing lives
// here rather than at each of the ways out.
if (currentScreen !== "pin") abandonPinSetup()
if (currentScreen !== "fingerprint") abandonFingerprintSetup()
restoreScreenFocus()
}
function restoreScreenFocus() {
Qt.callLater(function() {
if (status !== "unlocked") { focusAppropriateField(); return }
switch (currentScreen) {
case "main": searchField.forceActiveFocus(); return
case "edit": formNameField.forceActiveFocus(); return
// These open through a function that focuses their own first field.
case "pin": case "fingerprint": return
case "sends": if (sendMode === "create") return; break
}
// Everything else is keyboard-navigated rather than typed into.
keyCatcher.forceActiveFocus()
})
}
function setFormFolder(id) {
formFolderId = id
formPicker = ""
}
// Changing owner invalidates the collection choice: collections belong to a
// single organization, and a personal item cannot have any.
function setFormOrganization(id) {
formOrgId = id
formPicker = ""
formCollectionIds = []
formCollections = []
if (id && id !== "personal" && id !== "all") loadOrgCollections(id)
}
function loadOrgCollections(orgId) {
if (!session || !orgId) return
formCollectionsLoading = true
beginVaultRead("collections")
orgCollectionsProc.command = Model.listOrgCollectionsCommand(orgId)
orgCollectionsProc.running = true
}
function onOrgCollectionsLoaded(raw) {
formCollectionsLoading = false
if (vaultReadIsStale("collections")) return
formCollections = Model.parseCollections(raw)
// A single collection is not a choice; pre-select it.
if (formCollections.length === 1 && formCollectionIds.length === 0) {
formCollectionIds = [formCollections[0].id]
}
}
function toggleFormCollection(id) {
var next = []
var found = false
for (var i = 0; i < formCollectionIds.length; i++) {
if (formCollectionIds[i] === id) found = true
else next.push(formCollectionIds[i])
}
if (!found) next.push(id)
formCollectionIds = next
}
function isFormCollectionSelected(id) {
for (var i = 0; i < formCollectionIds.length; i++) {
if (formCollectionIds[i] === id) return true
}
return false
}
function formFolderLabel() {
if (!formFolderId) return "No Folder"
return Model.folderName(folders, formFolderId) || "No Folder"
}
function formOrgLabel() {
if (!formOrgId || formOrgId === "personal") return "My Vault"
for (var i = 0; i < organizations.length; i++) {
if (organizations[i].id === formOrgId) return organizations[i].name
}
return "My Vault"
}
function submitNewFolder() {
var name = String(newFolderName || "").trim()
if (!name) return
creatingFolder = true
beginVaultRead("folderCreate")
createFolderProc.command = Model.createFolderCommand()
createFolderProc.running = true
}
function onFolderCreated(exitCode, stdoutText) {
creatingFolder = false
if (vaultReadIsStale("folderCreate")) return
if (exitCode !== 0) {
errorMessage = "Could not create folder"
return
}
var created = null
try { created = JSON.parse(stdoutText) } catch (e) { created = null }
newFolderName = ""
// Creating a folder from the item form is only ever a prelude to filing
// the item into it, so select it straight away.
if (created && created.id) formFolderId = String(created.id)
flashNotification("Folder created")
loadFolders(true)
}
function syncVault() {
closeFilterGroup()
if (!session) return
isSyncing = true
beginVaultRead("sync")
syncProc.command = Model.syncCommand()
syncProc.running = true
}
function onSyncFinished(exitCode) {
if (vaultReadIsStale("sync")) return
if (exitCode === 0) {
itemsLoadedAt = 0
syncReloadPending = true
beginInitialVaultLoad(true, true)
} else {
isSyncing = false
syncReloadPending = false
errorMessage = "Sync failed"
}
}
function openDetail(item) {
closeFilterGroup()
if (!item || !item.id) return
learnFromPick(item)
isLoading = true
errorMessage = ""
revealedFields = ({})
showDeleteConfirm = false
detailItem = null
detailPassword = ""
liveTotp = ""
// Another item's downloads say nothing about this one's.
attachmentQueue = []
attachmentSaved = ({})
currentScreen = "detail"
// The list already fetched the whole item, so render from that rather than
// spending a second CLI round trip on data we are holding. Only fall back
// to `bw get item` if this item somehow arrived without its raw object.
var detail = item.rawObject ? Model.itemDetailFromObject(item.rawObject) : null
if (detail) {
isLoading = false
detailItem = detail
detailPassword = detail.password
} else {
beginVaultRead("detail")
if (item.typeCode === 5) {
isLoading = false
errorMessage = "SSH keys are read-only public records"
currentScreen = "main"
return
}
getItemProc.command = Model.getItemCommand(item.id, item.typeCode)
getItemProc.running = true
}
// The TOTP code is time-based, so it is the one thing the list cannot
// carry. It loads alongside rather than in front of the detail view.
if (item.hasTotp) {
fetchTotp(item.id)
}
}
function onDetailFinished(rawJson) {
isLoading = false
if (vaultReadIsStale("detail")) return
var parsed = Model.parseItemDetail(rawJson)
if (parsed) {
detailItem = parsed
detailPassword = parsed.password
} else {
errorMessage = "Could not load item details"
}
}
// -------------------------------------------------------------------------
// Attachments
// -------------------------------------------------------------------------
function cancelAttachmentDownloads() {
attachmentQueue = []
attachmentBusyId = ""
invalidateEpochOperation("attachment")
// A download holds decrypted bytes and the session it inherited at start.
// The supervised process group removes its private staging directory and
// cannot commit a file after the vault or panel has closed.
if (attachmentProc.running) attachmentProc.running = false
}
function queueAttachment(att) {
if (!detailItem || !att || !att.id) return
if (attachmentBusyId === att.id) return
for (var i = 0; i < attachmentQueue.length; i++) {
if (attachmentQueue[i].id === att.id) return
}
resetAutoLockTimer()
errorMessage = ""
var next = attachmentQueue.slice()
// The declared size travels with the job so the saver can refuse an
// oversized attachment before it starts, and check the disk has room.
next.push({ id: att.id, fileName: att.fileName, itemId: detailItem.id, size: att.size })
attachmentQueue = next
pumpAttachmentQueue()
}
function saveAllAttachments() {
if (!detailItem || !detailItem.attachments) return
for (var i = 0; i < detailItem.attachments.length; i++) {
queueAttachment(detailItem.attachments[i])
}
}
function pumpAttachmentQueue() {
if (attachmentBusyId !== "" || attachmentQueue.length === 0) return
if (!session) {
attachmentQueue = []
errorMessage = "Vault is locked or session expired. Please unlock your vault."
return
}
var next = attachmentQueue.slice()
var job = next.shift()
attachmentQueue = next
attachmentBusyId = job.id
beginVaultRead("attachment")
attachmentProc.command = Model.attachmentDownloadCommand(job.id, job.itemId, job.fileName, job.size)
attachmentProc.running = true
}
function onAttachmentDownloaded(exitCode, savedPath, stderrText) {
var id = attachmentBusyId
attachmentBusyId = ""
if (vaultReadIsStale("attachment")) return
var path = String(savedPath || "").trim()
if (exitCode !== 0 || !path) {
// bw's own message is the useful one -- "Not found." for an attachment
// that has since been deleted, or a permission error on the directory.
var err = String(stderrText || "").trim().split("\n")[0]
errorMessage = err ? ("Could not save the attachment: " + err)
: "Could not save the attachment"
attachmentQueue = []
return
}
var saved = {}
for (var k in attachmentSaved) saved[k] = attachmentSaved[k]
saved[id] = path
attachmentSaved = saved
flashNotification("Saved " + Model.baseName(path))
pumpAttachmentQueue()
}
function attachmentSavedPath(id) {
return (attachmentSaved && attachmentSaved[id]) ? String(attachmentSaved[id]) : ""
}
function isAttachmentQueued(id) {
for (var i = 0; i < attachmentQueue.length; i++) {
if (attachmentQueue[i].id === id) return true
}
return false
}
function openSavedAttachment(id) {
var path = attachmentSaved[id]
if (!path) return
resetAutoLockTimer()
Quickshell.execDetached(["xdg-open", path])
}
function revealSavedAttachment(id) {
var path = attachmentSaved[id]
if (!path) return
var dir = Model.parentDirectory(path)
if (!dir) return
resetAutoLockTimer()
Quickshell.execDetached(["xdg-open", dir])
}
function fetchTotp(itemId, copyWhenReady) {
if (!session || !itemId) return
if (copyWhenReady) totpCopyItemId = String(itemId)
if (getTotpProc.running || totpRestartPending) {
if (totpRequestItemId !== String(itemId)) {
totpQueuedItemId = String(itemId)
totpQueuedEpoch = vaultEpoch
}
return
}
startTotpFetch(String(itemId))
}
function startTotpFetch(itemId) {
if (!session || !itemId) return
totpRequestItemId = itemId
beginVaultRead("totp")
getTotpProc.command = Model.getTotpCommand(itemId)
getTotpProc.running = true
}
function onTotpProcessExited(exitCode, code) {
var itemId = totpRequestItemId
totpRequestItemId = ""
if (exitCode === 0) onTotpFinished(itemId, code)
else if (totpCopyItemId === itemId) {
totpCopyItemId = ""
errorMessage = "Could not read this TOTP code"
}
continueTotpQueue(false)
}
function continueTotpQueue(collectorIsClean) {
var queued = totpQueuedItemId
var queuedEpoch = totpQueuedEpoch
totpQueuedItemId = ""
totpQueuedEpoch = -1
if (queued) {
// Reserve this Process before deferring its restart. Without the flag, a
// newer request can start in this one-event-loop gap and then be
// overwritten by the older queued request.
totpRestartPending = true
totpRequestItemId = queued
Qt.callLater(function() {
root.totpRestartPending = false
if (queuedEpoch === root.vaultEpoch && root.session) root.startTotpFetch(queued)
else {
if (root.totpRequestItemId === queued) root.totpRequestItemId = ""
if (!collectorIsClean) root.clearProcessCollectorSoon(getTotpProc)
}
})
}
else if (!collectorIsClean) clearProcessCollectorSoon(getTotpProc)
}
function onTotpFinished(itemId, code) {
if (vaultReadIsStale("totp")) return
var c = String(code || "").trim()
if (detailItem && detailItem.id === itemId) liveTotp = c
if (totpFollowupActive && totpFollowupItem && totpFollowupItem.id === itemId) {
totpFollowupCode = c
}
if (totpCopyItemId === itemId) {
totpCopyItemId = ""
if (c) copyToClipboard(c, "TOTP code")
else errorMessage = "Could not read this TOTP code"
}
}
// -------------------------------------------------------------------------
// CRUD Operations (Add, Edit, Delete)
// -------------------------------------------------------------------------
// The card or identity boxes, in the shape buildCreatePayload and
// buildEditPayload want. Returns null for a login or a note, and null is
// exactly what tells buildEditPayload to leave an existing sub-object alone.
function formTypeFields() {
if (formTypeCode === 3) {
return {
cardholderName: formCardholderName, brand: formCardBrand,
number: formCardNumber, expMonth: formCardExpMonth,
expYear: formCardExpYear, code: formCardCode
}
}
if (formTypeCode === 4) {
return {
title: formIdTitle, firstName: formIdFirstName,
middleName: formIdMiddleName, lastName: formIdLastName,
username: formIdUsername, company: formIdCompany,
email: formIdEmail, phone: formIdPhone, ssn: formIdSsn,
passportNumber: formIdPassport, licenseNumber: formIdLicense,
address1: formIdAddress1, address2: formIdAddress2,
address3: formIdAddress3, city: formIdCity, state: formIdState,
postalCode: formIdPostalCode, country: formIdCountry
}
}
return null
}
// Every card and identity box, emptied. Called wherever the form resets so
// a new item never opens wearing the last one's card number.
function clearTypeFields() {
formCardholderName = ""; formCardBrand = ""; formCardNumber = ""
formCardExpMonth = ""; formCardExpYear = ""; formCardCode = ""
formIdTitle = ""; formIdFirstName = ""; formIdMiddleName = ""
formIdLastName = ""; formIdUsername = ""; formIdCompany = ""
formIdEmail = ""; formIdPhone = ""; formIdSsn = ""
formIdPassport = ""; formIdLicense = ""; formIdAddress1 = ""
formIdAddress2 = ""; formIdAddress3 = ""; formIdCity = ""
formIdState = ""; formIdPostalCode = ""; formIdCountry = ""
}
function loadTypeFields(item) {
clearTypeFields()
if (!item) return
var c = item.card || null
if (c) {
formCardholderName = String(c.cardholderName || "")
formCardBrand = String(c.brand || "")
formCardNumber = String(c.number || "")
formCardExpMonth = String(c.expMonth || "")
formCardExpYear = String(c.expYear || "")
formCardCode = String(c.code || "")
}
var d = item.identity || null
if (d) {
formIdTitle = String(d.title || "")
formIdFirstName = String(d.firstName || "")
formIdMiddleName = String(d.middleName || "")
formIdLastName = String(d.lastName || "")
formIdUsername = String(d.username || "")
formIdCompany = String(d.company || "")
formIdEmail = String(d.email || "")
formIdPhone = String(d.phone || "")
formIdSsn = String(d.ssn || "")
formIdPassport = String(d.passportNumber || "")
formIdLicense = String(d.licenseNumber || "")
formIdAddress1 = String(d.address1 || "")
formIdAddress2 = String(d.address2 || "")
formIdAddress3 = String(d.address3 || "")
formIdCity = String(d.city || "")
formIdState = String(d.state || "")
formIdPostalCode = String(d.postalCode || "")
formIdCountry = String(d.country || "")
}
}
function startAddNewItem() {
closeFilterGroup()
formIsEditing = false
formItemId = ""
formTypeCode = 1
clearTypeFields()
formName = ""
formUsername = ""
formPassword = ""
formTotp = ""
formUri = ""
formNotes = ""
formFavorite = false
formOrgId = selectedOrg !== "all" ? selectedOrg : ""
formFolderId = (selectedFolder !== "all" && selectedFolder !== "none") ? selectedFolder : ""
newFolderName = ""
formPicker = ""
formCollections = []
formCollectionIds = []
if (formOrgId && formOrgId !== "personal") loadOrgCollections(formOrgId)
formPasswordRevealed = false
errorMessage = ""
currentScreen = "edit"
}
// The item form as one object, so a save that fails can be reopened exactly
// as it was rather than costing the user everything they typed.
function captureItemForm() {
return {
isEditing: formIsEditing, itemId: formItemId, typeCode: formTypeCode,
name: formName, username: formUsername, password: formPassword,
totp: formTotp, uri: formUri, notes: formNotes, favorite: formFavorite,
orgId: formOrgId, folderId: formFolderId,
collectionIds: (formCollectionIds || []).slice(),
typeFields: formTypeFields()
}
}
function restoreItemForm(f) {
if (!f) return
formIsEditing = f.isEditing
formItemId = f.itemId
formTypeCode = f.typeCode
formName = f.name
formUsername = f.username
formPassword = f.password
formTotp = f.totp
formUri = f.uri
formNotes = f.notes
formFavorite = f.favorite
formOrgId = f.orgId
formFolderId = f.folderId
formCollectionIds = (f.collectionIds || []).slice()
loadTypeFields({ card: f.typeCode === 3 ? f.typeFields : null,
identity: f.typeCode === 4 ? f.typeFields : null })
formPicker = ""
formPasswordRevealed = false
if (formOrgId && formOrgId !== "personal") loadOrgCollections(formOrgId)
currentScreen = "edit"
}
// Reopens the form a refused save was made from.
function reopenFailedSave() {
if (!failedSave) return
var f = failedSave.form
failedSave = null
errorMessage = ""
restoreItemForm(f)
}
function startEditItem(item) {
if (!item || item.typeCode === 5) {
if (item && item.typeCode === 5) errorMessage = "SSH keys are read-only public records"
return
}
// The vault has not answered about this row yet, and on a create it does
// not have an id to edit. Editing it would race the save it is waiting on.
if (item.pending) {
errorMessage = "Still saving this item -- one moment"
return
}
formIsEditing = true
formItemId = item.id
formTypeCode = item.typeCode || 1
formName = item.name || ""
formUsername = item.username || ""
formPassword = detailPassword || (item.rawObject && item.rawObject.login ? item.rawObject.login.password : "") || ""
formTotp = item.totpKey || (item.rawObject && item.rawObject.login ? item.rawObject.login.totp : "") || ""
formUri = item.uris && item.uris.length > 0 ? item.uris[0] : ""
formNotes = item.notes || ""
formFavorite = Boolean(item.favorite)
formOrgId = item.organizationId || ""
formFolderId = item.folderId || ""
newFolderName = ""
formPicker = ""
formCollections = []
// Editing keeps whatever collections the item already has until changed.
formCollectionIds = (item.rawObject && item.rawObject.collectionIds)
? item.rawObject.collectionIds.slice() : []
// The list row carries the parsed card and identity, so an edit opens with
// the real values in the boxes rather than blanks that would be written
// straight back over them on save.
loadTypeFields(item)
if (formOrgId && formOrgId !== "personal") loadOrgCollections(formOrgId)
formPasswordRevealed = false
errorMessage = ""
currentScreen = "edit"
}
// A save takes as long as `bw` takes -- a second or two of CLI startup, vault
// decryption and a round trip, none of which this plugin can shorten. What it
// can do is stop making the user watch. The form closes as soon as the
// command is launched and the list shows the item as it will be, marked as
// saving, and the authoritative row replaces it when the vault answers.
//
// One at a time. There is a single process per kind, and starting a second
// command on a running one would lose the first; a save while one is in
// flight is refused with a reason rather than silently dropped.
function saveItemForm() {
if (pendingSave) {
errorMessage = "Still saving " + pendingSave.name + " -- one moment"
return
}
// Bitwarden refuses an organization item with no collection; say so here
// rather than letting the CLI fail after the form is gone.
var problem = Model.validateItemForm(formName, formOrgId, formCollectionIds)
if (problem) {
errorMessage = problem
return
}
var editing = formIsEditing
var payload = editing
? Model.buildEditPayload(detailItem, formName, formUsername, formPassword, formTotp, formUri, formNotes, formFavorite, formOrgId, formFolderId, formCollectionIds, formTypeFields())
: Model.buildCreatePayload(formTypeCode, formName, formUsername, formPassword, formTotp, formUri, formNotes, formFavorite, formOrgId, formFolderId, formCollectionIds, formTypeFields())
if (!payload) {
errorMessage = editing ? "This item is read-only" : "This item type is read-only"
return
}
errorMessage = ""
beginVaultRead("itemSave")
// An edit keeps the item's id; a create has none until the server assigns
// one, so the row carries a provisional id the response swaps out.
var rowId = editing ? formItemId : Model.pendingItemId(Date.now())
var optimistic = Model.optimisticItem(payload, rowId)
pendingSave = {
id: rowId,
isCreate: !editing,
name: String(formName || "Untitled").trim(),
// What the list held before, so a failed save can put it back rather
// than leaving the panel showing something the vault never accepted.
previous: editing ? Model.findItemById(items, rowId) : null,
// The form as it was, so a failed save can be reopened and retried
// instead of costing the user everything they typed.
form: captureItemForm()
}
itemPayloadJson = JSON.stringify(payload)
if (editing) {
editItemProc.command = Model.editItemCommand(formItemId, formTypeCode)
editItemProc.running = true
} else {
createItemProc.command = Model.createItemCommand(payload)
createItemProc.running = true
}
if (optimistic) {
items = Model.replaceItemById(items, rowId, optimistic)
itemsLoadedAt = Date.now()
refreshDerivedFromItems()
}
currentScreen = "main"
}
function onSaveItemFinished(exitCode, stdoutText, stderrText) {
isLoading = false
// The payload carries the item's password in the clear, the same way a
// Send payload does, so it goes the same way the Send one does: as soon as
// the process that needed it has exited.
itemPayloadJson = ""
var save = pendingSave
pendingSave = null
if (vaultReadIsStale("itemSave")) return
if (exitCode !== 0) {
// The vault refused it, so the list must stop showing it as though it
// had not. The optimistic row is taken back out -- replaced by what was
// there before on an edit, removed entirely on a create -- and what the
// user typed is kept so they can reopen it instead of retyping it.
if (save) {
items = Model.replaceItemById(items, save.id, save.previous)
itemsLoadedAt = Date.now()
refreshDerivedFromItems()
failedSave = { name: save.name, form: save.form }
errorMessage = "Could not save " + save.name + ". " + (stderrText || "")
} else {
errorMessage = stderrText || "Failed to save item"
}
return
}
flashNotification(save && save.isCreate ? "Item created successfully!" : "Item updated successfully!")
// The save printed the item the vault now holds, so the list can be
// brought up to date from that instead of re-reading and re-decrypting
// every other item to learn about this one. On a create the row being
// replaced is the provisional one, whose id the server has just assigned.
//
// Any doubt falls back to the full read. The command prints a marker when
// the item was stored but could not be sanitised, and spliceSavedItem
// returns null on an envelope it does not recognise; in both cases the
// item is in the vault and the list simply has to catch up the slow way.
// A list that quietly disagrees with the vault is worse than a slow one.
var spliced = String(stdoutText).indexOf(Model.savedUnsanitizedMarker()) === 0
? null : Model.spliceSavedItem(items, stdoutText, save ? save.id : "")
if (!spliced) {
// A provisional row must never survive a reload it is not part of.
if (save && save.isCreate) items = Model.replaceItemById(items, save.id, null)
loadItems()
return
}
items = spliced
itemsLoadedAt = Date.now()
refreshDerivedFromItems()
}
// A delete costs the same second or two of `bw` a save does, and used to
// spend it on a frozen detail screen and then spend more of it re-reading
// the whole vault to learn about the one row that had gone. The row goes
// now and the panel comes back; if the vault refuses, the row returns.
function deleteCurrentItem() {
if (!detailItem || !detailItem.id || detailItem.typeCode === 5) return
if (detailItem.pending || Model.isPendingItemId(detailItem.id)) {
errorMessage = "Still saving this item -- one moment"
return
}
if (pendingDelete) {
errorMessage = "Still deleting " + pendingDelete.name + " -- one moment"
return
}
var id = detailItem.id
pendingDelete = {
id: id,
name: String(detailItem.name || "this item"),
// The row as the list holds it, so a refusal can put it back exactly.
previous: Model.findItemById(items, id)
}
beginVaultRead("itemDelete")
deleteItemProc.command = Model.deleteItemCommand(id, detailItem.typeCode)
deleteItemProc.running = true
showDeleteConfirm = false
items = Model.replaceItemById(items, id, null)
itemsLoadedAt = Date.now()
refreshDerivedFromItems()
currentScreen = "main"
}
function onDeleteItemFinished(exitCode, stdoutText, stderrText) {
isLoading = false
showDeleteConfirm = false
var removal = pendingDelete
pendingDelete = null
if (vaultReadIsStale("itemDelete")) return
if (exitCode === 0) {
// The row is already gone and nothing else about the vault changed, so
// there is nothing left to read.
flashNotification("Item deleted")
return
}
// Still in the vault, so it belongs back in the list. Nothing was typed
// here, so putting the row back is the whole of the recovery.
if (removal && removal.previous) {
items = Model.replaceItemById(items, removal.id, removal.previous)
itemsLoadedAt = Date.now()
refreshDerivedFromItems()
errorMessage = "Could not delete " + removal.name + ". " + (stderrText || "")
} else {
errorMessage = stderrText || "Failed to delete item"
}
}
// -------------------------------------------------------------------------
// Filtering & Selection
// -------------------------------------------------------------------------
// Everything downstream of `items`. Suggestions are derived from the item
// list too, so a change to it that only called rebuildFilter() would leave
// the suggested rows describing the vault as it was. Both the full load and
// a single spliced save come through here so they cannot drift.
function refreshDerivedFromItems() {
if (activeWindowData) {
handleActiveWindowDetected(activeWindowData)
} else {
rebuildFilter()
}
}
function rebuildFilter() {
var baseList = Model.filterItems(items, searchQuery, selectedCategory, selectedOrg, selectedFolder)
if (searchQuery.trim() === "" && selectedCategory === "all" && selectedOrg === "all" && selectedFolder === "all" && !suggestionsDismissed && suggestedItems.length > 0) {
var suggestedIds = {}
var topMatches = []
for (var s = 0; s < suggestedItems.length; s++) {
var sItem = Object.assign({}, suggestedItems[s], { isSuggested: true })
topMatches.push(sItem)
suggestedIds[sItem.id] = true
}
var otherItems = []
for (var o = 0; o < baseList.length; o++) {
if (!suggestedIds[baseList[o].id]) {
otherItems.push(baseList[o])
}
}
filteredItems = topMatches.concat(otherItems)
} else {
filteredItems = baseList
}
if (selectedIndex >= filteredItems.length) {
selectedIndex = Math.max(0, filteredItems.length - 1)
}
if (selectedIndex < 0 && filteredItems.length > 0) {
selectedIndex = 0
}
}
// What the list says when it has nothing to show. The SSH filter gets its own
// answer: a vault that returned no SSH keys is not the same as a server that
// never confirmed it can store them, and only the first is worth waiting on.
function emptyListMessage() {
if (selectedCategory === "sshKey" && filteredItems.length === 0 && sshCapability
&& sshCapability.state === "unconfirmed") {
return sshCapability.message
}
if (items.length === 0) return "Vault is empty"
return "No items match '" + searchQuery + "'"
}
function selectCategory(catId) {
selectedCategory = catId === "sshKey" && !sshUiAvailable ? "all" : catId
selectedIndex = 0
rebuildFilter()
}
function selectOrganization(orgId) {
selectedOrg = orgId
selectedIndex = 0
rebuildFilter()
}
function cycleCategory(delta) {
var currentIndex = 0
for (var i = 0; i < visibleCategories.length; i++) {
if (visibleCategories[i].id === selectedCategory) {
currentIndex = i
break
}
}
var nextIndex = (currentIndex + delta + visibleCategories.length) % visibleCategories.length
selectCategory(visibleCategories[nextIndex].id)
}
// Every main-screen shortcut in one place. Reached two ways: bare letters
// when the list has focus, and Alt+letter from inside the search box, where
// a bare letter is search text and must stay that way.
// Alt+letter. Same table as the bare letters, except Alt+s opens Sends --
// Send has no bare letter of its own, and plain s is already Settings.
function runAltShortcut(lower) {
// Alt+s is Send, which has no bare letter of its own, so Settings keeps
// its own Alt binding on the comma rather than losing one.
if (lower === "s") { openSends(); return true }
if (lower === ",") { openSettings(); return true }
return runShortcut(lower)
}
function runShortcut(lower) {
var item = getSelectedItem()
switch (lower) {
case "y": case "p": if (item) copyPassword(item); return true
case "u": case "c": if (item) copyUsername(item); return true
case "m": if (item && item.hasTotp) copyTotpCode(item); return true
case "w": if (item && item.uris && item.uris.length > 0) openUrl(item.uris[0]); return true
case "e": if (item) openDetail(item); return true
case "n": startAddNewItem(); return true
case "l": lockVault(); return true
case "r": syncVault(); return true
case "f": toggleFilterGroup("folders"); return true
case "o": toggleFilterGroup("organizations"); return true
case "t": toggleFilterGroup("types"); return true
case "g": openGenerator(); return true
case "s": openSettings(); return true
}
return false
}
function moveCursor(delta) {
if (filteredItems.length === 0) return
// Moving to an item means the user is done filtering; get the list out of
// the way rather than leaving it covering the results.
openFilterGroup = ""
selectedIndex = Math.max(0, Math.min(filteredItems.length - 1, selectedIndex + delta))
if (itemsListView) {
itemsListView.positionViewAtIndex(selectedIndex, ListView.Contain)
}
}
function getSelectedItem() {
if (filteredItems.length === 0 || selectedIndex < 0 || selectedIndex >= filteredItems.length) {
return null
}
return filteredItems[selectedIndex]
}
// -------------------------------------------------------------------------
// Clipboard Actions & Sequential Password -> TOTP Follow-Up
// -------------------------------------------------------------------------
function copyToClipboard(text, label) {
if (!text) return
resetAutoLockTimer()
// The value goes through the environment: `printf %s '<secret>'` would put
// the password or TOTP code straight into /proc/<pid>/cmdline. Remove that
// variable before starting wl-copy, whose clipboard owner can outlive this
// short shell after it forks into the background.
Quickshell.execDetached({
command: ["bash", "-c", "printf '%s' \"$QSBW_CLIP\" | env -u QSBW_CLIP wl-copy --sensitive"],
environment: { "QSBW_CLIP": String(text) }
})
flashNotification(label + " copied!")
if (clearClipboardSec > 0) {
clipboardClearTimer.restart()
}
}
function clearClipboard() {
clipboardClearTimer.stop()
Quickshell.execDetached(["wl-copy", "--clear"])
}
function requestPasswordCopy(itemId, typeCode) {
if (!session || !itemId) return
if (copyPasswordProc.running) {
errorMessage = "Another password copy is still loading"
return
}
passwordCopyItemId = String(itemId)
beginVaultRead("passwordCopy")
copyPasswordProc.command = Model.getPasswordCommand(itemId, typeCode)
copyPasswordProc.running = true
}
function onPasswordCopyFinished(exitCode, text) {
var requested = passwordCopyItemId
passwordCopyItemId = ""
// The clipboard has its own expiry; the pipe buffer needs one too. Once
// the value has been handed to wl-copy there is no reason to keep a second
// plaintext copy in this long-lived Process object.
clearProcessCollectorSoon(copyPasswordProc)
if (vaultReadIsStale("passwordCopy")) return
var password = String(text || "")
if (exitCode === 0 && requested && password) {
copyToClipboard(password, "Password")
return
}
errorMessage = "Could not read this password"
}
// Smart sequential Enter handler: Copies Password, then arms and auto-copies TOTP
// Enter on a list row does the obvious thing for the item under it. For a
// login that is "copy the password", which is what this used to be and the
// only thing it did: every other type fell out of the guard below and Enter
// did nothing at all, on an item whose whole content was one keystroke away.
//
// A card, an identity, a note and an SSH key have no default secret to put
// on the clipboard, and neither does a login that was saved without a
// password. In all of those cases the useful answer is to open the item,
// which is what a user pressing Enter on a row they cannot copy from was
// reaching for anyway.
function handleSmartEnter(item) {
openFilterGroup = ""
if (!item) return
var copyable = Model.isLoginItem(item)
&& (item.hasPassword !== undefined ? item.hasPassword : Boolean(item.password))
if (!copyable) {
openDetail(item)
return
}
// If already in active TOTP follow-up mode for this item, copy TOTP now!
if (totpFollowupActive && totpFollowupItem && totpFollowupItem.id === item.id) {
copyTotpCode(item)
totpFollowupActive = false
if (closeOnCopy) close()
return
}
// Step 1: Copy password
copyPassword(item)
// Step 2: If item has TOTP, arm follow-up and schedule auto-copy!
if (item.hasTotp) {
totpFollowupItem = item
totpFollowupActive = true
fetchTotp(item.id)
totpFollowupTimer.restart()
if (autoCopyTotpSec > 0) {
autoTotpTimer.interval = autoCopyTotpSec * 1000
autoTotpTimer.restart()
}
}
if (closeOnCopy) {
close()
}
}
function copyPassword(item) {
closeFilterGroup()
if (!item || !Model.isLoginItem(item)) return
learnFromPick(item)
var pass = (detailItem && detailItem.id === item.id && detailPassword) ? detailPassword : (item.password || "")
if (pass) {
copyToClipboard(pass, "Password")
return
}
if (session) {
requestPasswordCopy(item.id, item.typeCode)
} else {
errorMessage = "Vault is locked or session expired. Please unlock your vault."
}
}
function copyUsername(item) {
closeFilterGroup()
if (!item || !item.username) return
copyToClipboard(item.username, "Username")
}
function copyTotpCode(item) {
closeFilterGroup()
if (!item || !Model.isLoginItem(item)) return
if (liveTotp && item.id === (detailItem ? detailItem.id : "")) {
copyToClipboard(liveTotp, "TOTP code")
return
}
if (totpFollowupActive && totpFollowupItem && totpFollowupItem.id === item.id && totpFollowupCode) {
copyToClipboard(totpFollowupCode, "TOTP code")
return
}
fetchTotp(item.id, true)
}
function openUrl(url) {
if (!url) return
// Only http and https are handed to xdg-open; see normalizeOpenableUrl().
var resolved = Model.normalizeOpenableUrl(url)
if (!resolved.ok) {
errorMessage = resolved.reason === "ambiguous"
? "Refusing to open an ambiguous link containing a backslash"
: resolved.scheme
? ("Refusing to open a " + resolved.scheme + ": link -- only http and https are opened")
: "That item has no link to open"
return
}
Quickshell.execDetached(["xdg-open", resolved.url])
flashNotification("Opening " + resolved.url)
}
function flashNotification(msg) {
flashMessage = msg
flashTimer.restart()
}
function resetAutoLockTimer() {
// Recorded even when auto-lock is off, so turning it back on mid-session
// starts counting from the last thing the user did rather than from zero.
autoLockArmedAt = Date.now()
if (autoLockMinutes > 0) {
autoLockTimer.interval = autoLockMinutes * 60 * 1000
autoLockTimer.restart()
}
}
// -------------------------------------------------------------------------
// Timers
// -------------------------------------------------------------------------
Timer {
id: searchDebounceTimer
interval: 50
repeat: false
onTriggered: root.rebuildFilter()
}
Timer {
id: deferredMetadataTimer
// One frame at 60 Hz is ~17 ms. Fifty milliseconds leaves room for the
// parsed item model to polish and render before two more bw processes
// begin their startup work.
interval: 50
repeat: false
onTriggered: {
if (root.status !== "unlocked" || !root.metadataLoadPending) return
var force = root.metadataForceRefresh
root.metadataLoadPending = false
root.metadataForceRefresh = false
root.loadOrganizations(force)
root.loadFolders(force)
if (root.statusRefreshAfterItems) {
root.statusRefreshAfterItems = false
root.runStatusCheck(false)
}
}
}
Timer {
id: flashTimer
interval: 2500
onTriggered: root.flashMessage = ""
}
Timer {
id: totpFollowupTimer
interval: 8000
onTriggered: root.totpFollowupActive = false
}
Timer {
id: autoTotpTimer
repeat: false
onTriggered: {
if (root.totpFollowupItem && root.totpFollowupItem.hasTotp) {
root.copyTotpCode(root.totpFollowupItem)
// The code itself stays out of the notification. It is already on the
// clipboard, and a notification is not a private channel: the daemon
// keeps history and can render the body over a lock screen. The panel
// shows the digits on screen instead, where you asked for them.
Quickshell.execDetached(["omarchy-notification-send", "-g", "󰥔", "--app-name", "Bitwarden", "-t", "4000", "TOTP Code Copied", "2FA verification code ready to paste"])
root.totpFollowupActive = false
}
}
}
Timer {
id: clipboardClearTimer
interval: root.clearClipboardSec * 1000
onTriggered: root.clearClipboard()
}
Timer {
id: autoLockTimer
interval: root.autoLockMinutes * 60 * 1000
running: root.status === "unlocked" && root.autoLockMinutes > 0
onTriggered: {
if (root.status === "unlocked") {
root.lockVault()
}
}
}
// The timer above measures the time the shell was awake for, which on a
// laptop is not the time the vault was exposed for: Qt schedules on
// CLOCK_MONOTONIC and Linux stops that clock across a suspend, so a lock
// armed before the lid closed still had its full countdown left when the lid
// opened. This is the wall-clock half of the same deadline; see the
// Auto-lock section of BitwardenModel.js.
Timer {
id: autoLockWatchdog
interval: Model.autoLockPollMs(root.autoLockMinutes)
repeat: true
running: root.status === "unlocked" && root.autoLockMinutes > 0
onTriggered: {
if (root.status !== "unlocked") return
// An unlock that somehow reached us without arming the window starts it
// here rather than reading a deadline of "1970 plus fifteen minutes".
if (root.autoLockArmedAt <= 0) {
root.autoLockArmedAt = Date.now()
return
}
if (Model.autoLockExpired(root.autoLockArmedAt, root.autoLockMinutes, Date.now())) {
root.lockVault()
}
}
}
// -------------------------------------------------------------------------
// Locking on screen lock and on suspend
// -------------------------------------------------------------------------
//
// Both are the same conclusion the auto-lock reaches on a timer, arrived at
// from evidence instead: the vault is no longer being attended. Neither
// replaces the countdown -- a vault left open at an unlocked desk is still
// the case only elapsed time can catch.
// The last reading from the screen-lock poll, with the moment it was taken.
// The agent needs this even when lockOnScreenLock is off, because it must
// never raise an approval prompt over a locked screen.
property bool screenIsLocked: false
property double screenLockCheckedAt: 0
function onScreenLockState(raw) {
root.screenIsLocked = Model.screenIsLocked(raw)
root.screenLockCheckedAt = Date.now()
if (!lockOnScreenLock || status !== "unlocked") return
if (root.screenIsLocked) lockVault()
}
function onSleepSignal(line) {
var token = String(line || "").trim()
if (token === Model.wakeSignalToken()) {
// Coming back is not by itself a reason to do anything -- the watchdog
// below already notices a countdown that expired across the suspend --
// but the panel should not be showing a vault state from before the lid
// closed either.
if (opened) refreshStatus()
return
}
if (token !== Model.sleepSignalToken()) return
if (!lockOnSuspend || status !== "unlocked") return
// Synchronous as far as the session key in this process is concerned; the
// keyring clear it spawns is what the inhibitor's held second is for.
lockVault()
}
Timer {
id: screenLockPoll
interval: Model.screenLockPollMs()
repeat: true
// Nothing to ask while the setting is off or the vault is already locked,
// which between them is every state but the one this is for.
// Also while the agent is serving: an approval prompt must never appear
// over a locked screen, and that needs a current reading regardless of
// whether the vault is set to lock with the screen.
running: (root.lockOnScreenLock && root.status === "unlocked") || root.sshAgentGateOpen
onTriggered: {
if (!screenLockStateProc.running) screenLockStateProc.running = true
}
}
// Comes back for the processes that were mid-read when the vault locked.
// Stops as soon as the queue empties, which is the same tick for everything
// that was already idle.
Timer {
id: scrubRetry
interval: Model.scrubRetryMs()
repeat: true
onTriggered: {
root.scrubStep()
if (!root.scrubPending.length) stop()
}
}
Process {
id: screenLockStateProc
command: Model.screenLockStateCommand()
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.onScreenLockState(text)
}
}
Process {
id: sshAgentHelperProc
stdout: StdioCollector {
id: sshAgentHelperStdout
waitForEnd: true
onStreamFinished: root.onSshAgentHelperInspected(text)
}
}
Process {
id: sshExportProc
command: Model.sshExportCommand()
stdinEnabled: true
stdout: StdioCollector { id: sshExportStdout; waitForEnd: true }
onExited: function(exitCode) {
sshExportProc.stdinEnabled = true
root.onSshExportFinished(exitCode, sshExportStdout.text)
}
}
Process {
id: sshExportClearProc
command: Model.sshExportClearCommand()
stdout: StdioCollector { id: sshExportClearStdout; waitForEnd: true }
onExited: function(exitCode) { root.onSshExportFinished(exitCode, sshExportClearStdout.text) }
}
Process {
id: loadIdProc
command: Model.loadIdCommand()
stdout: StdioCollector {
id: loadIdStdout
waitForEnd: true
onStreamFinished: root.onSshAgentLoadIdRead(text)
}
}
Process {
id: uwsmInspectProc
command: Model.uwsmInspectCommand()
stdout: StdioCollector {
id: uwsmInspectStdout
waitForEnd: true
onStreamFinished: {
root.uwsmFragment = Model.parseUwsmInspection(text)
root.applyUwsmRestore()
}
}
}
Process {
id: pluginDataRemoveProc
command: Model.pluginDataRemoveCommand()
stdout: StdioCollector { id: pluginDataRemoveStdout; waitForEnd: true }
onExited: function(exitCode) { root.onPluginDataRemoved(exitCode, pluginDataRemoveStdout.text) }
}
Process {
id: uwsmWriteProc
command: Model.uwsmWriteCommand()
stdout: StdioCollector { id: uwsmWriteStdout; waitForEnd: true }
onExited: function(exitCode) { root.onUwsmActionFinished(exitCode, uwsmWriteStdout.text) }
}
Process {
id: uwsmRemoveProc
command: Model.uwsmRemoveCommand()
stdout: StdioCollector { id: uwsmRemoveStdout; waitForEnd: true }
onExited: function(exitCode) { root.onUwsmActionFinished(exitCode, uwsmRemoveStdout.text) }
}
// The SSH companion. Tracked and non-detached so it dies with the shell and
// with a configuration reload, rather than outliving the panel that holds
// its control channel: the helper treats stdin EOF as "drop the keys and
// exit", and that only works if this Process really owns the child.
//
// clearEnvironment strips everything the shell was started with -- PATH,
// HOME, and above all BW_SESSION -- and `environment` puts back the single
// variable the helper reads. It runs no `bw` and spawns nothing, so it needs
// nothing else.
Process {
id: sshAgentProc
// Whichever candidate the inspection accepted -- the shipped artifact by
// preference, a local development build otherwise.
command: Model.sshAgentHelperCommand(root.sshAgentPluginDir, root.sshAgentHelper.source)
clearEnvironment: true
environment: Model.sshAgentHelperEnv(root.sshAgentRuntimeDir) || ({})
stdinEnabled: true
// Attached from startup, so the `ready` that answers hello cannot be
// missed by a parser wired up after the fact.
stdout: SplitParser {
onRead: function(line) { root.applySshAgentEvent({ kind: "line", line: line, nowMs: Date.now() }) }
}
onStarted: root.applySshAgentEvent({ kind: "started", nowMs: Date.now() })
onExited: function(exitCode) {
sshAgentTerminateTimer.stop()
root.applySshAgentEvent({ kind: "exited", exitCode: exitCode, nowMs: Date.now() })
}
}
// The bound on the handshake. QML never waits for `ready`; it arms this and
// carries on, and a helper that has not answered by the time it fires is
// stopped and retried like any other failure.
Timer {
id: sshAgentHandshakeTimer
interval: Model.sshAgentHandshakeTimeoutMs()
repeat: false
running: root.sshAgentPhase === "starting" || root.sshAgentPhase === "handshaking"
onTriggered: root.applySshAgentEvent({ kind: "handshakeTimeout", nowMs: Date.now() })
}
// Only while there is something to count down. A grant is at most fifteen
// minutes, so this is never a timer that runs for the life of the shell.
Timer {
id: sshGrantCountdown
interval: 1000
repeat: true
running: root.sshGrantsAnnounced.length > 0
onTriggered: root.sshGrantTick = Date.now()
}
Timer {
id: sshCooldownCountdown
interval: 1000
repeat: true
running: root.sshCooldownStatus.active
onTriggered: root.noteSshCooldown()
}
Timer {
id: sshPromptCountdown
interval: 1000
repeat: true
running: root.sshPrompt !== null || root.sshUnlockRequest !== null
onTriggered: {
var elapsed = Date.now() - root.sshPromptStartedMs
var remaining = Math.ceil((Model.sshAgentRequestDeadlineMs() - elapsed) / 1000)
root.sshPromptRemainingSec = Math.max(0, remaining)
if (remaining <= 0) root.expireSshRequest()
}
}
// The grace period between asking the helper to shut down and making it.
// Two seconds is far longer than dropping keys and unlinking two paths
// takes, and short enough that a wedged helper does not delay a restart.
Timer {
id: sshAgentTerminateTimer
interval: 2000
repeat: false
onTriggered: if (sshAgentProc.running) sshAgentProc.running = false
}
// Capped restart backoff. The interval is set by the reducer before each
// restart; the timer only reports that it elapsed.
Timer {
id: sshAgentRestartTimer
repeat: false
onTriggered: root.applySshAgentEvent({ kind: "restartTimer", nowMs: Date.now() })
}
// Long-lived: it holds the sleep inhibitor that makes the lock land before
// the machine is frozen, so it runs whenever the setting is on rather than
// only while the vault happens to be unlocked -- a suspend announcement is
// no use to a panel that started listening after it.
Process {
id: sleepMonitorProc
running: root.lockOnSuspend
command: Model.sleepMonitorCommand()
stdout: SplitParser {
onRead: function(line) { root.onSleepSignal(line) }
}
}
Timer {
id: totpCountdownTimer
interval: 1000
running: root.opened && (root.currentScreen === "detail" || root.totpFollowupActive)
repeat: true
onTriggered: {
var sec = 30 - (Math.floor(Date.now() / 1000) % 30)
root.totpSecRemaining = sec
if (sec === 30) {
if (root.currentScreen === "detail" && root.detailItem && root.detailItem.hasTotp) {
root.fetchTotp(root.detailItem.id)
} else if (root.totpFollowupActive && root.totpFollowupItem) {
root.fetchTotp(root.totpFollowupItem.id)
}
}
}
}
// -------------------------------------------------------------------------
// Processes (Quickshell.Io)
// -------------------------------------------------------------------------
Process {
id: statusProc
environment: root.bwEnv()
stdout: StdioCollector {
id: statusStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(statusProc)) return
root.onStatusFinished(exitCode === 0 ? statusStdout.text : "")
}
}
Process {
id: sessionHandoffProc
// Set by refreshStatus(), which decides whether this is a read or a
// discard. Defaults to the discard form so a run that somehow starts
// without going through there cannot adopt a key -- and a scrub, which
// replaces this command with one that reads nothing at all, only makes
// that stricter.
command: Model.sessionHandoffReadCommand(false)
stdout: StdioCollector {
id: sessionHandoffStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(sessionHandoffProc)) return
root.onSessionHandoff(exitCode === 0 ? sessionHandoffStdout.text : "")
}
}
Process {
id: keyringLookupProc
command: Model.keyringLookupCommand()
stdout: StdioCollector {
id: keyringLookupStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(keyringLookupProc)) return
root.onKeyringLookupFinished(exitCode === 0 ? keyringLookupStdout.text : "")
}
}
Process {
id: keyringStoreProc
command: Model.keyringStoreCommand()
environment: root.secretEnv(root.session)
onExited: function(exitCode) {
root.onSessionStored(exitCode)
if (root.logoutPending && root.allCredentialsClearPending)
Qt.callLater(root.requestAllCredentialClear)
}
}
Process {
id: keyringClearProc
command: Model.keyringClearCommand()
onExited: function(exitCode) {
if (root.sessionClearPending) {
Qt.callLater(root.requestSessionCredentialClear)
return
}
if (root.sessionStorePending) Qt.callLater(root.storeCurrentSession)
}
}
// ---- Fingerprint unlock ----
Process {
id: listFoldersProc
environment: root.bwEnv()
stdout: StdioCollector {
id: listFoldersStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(listFoldersProc)) return
if (exitCode === 0) root.onListFoldersFinished(listFoldersStdout.text)
}
}
Process {
id: orgCollectionsProc
environment: root.bwEnv()
stdout: StdioCollector {
id: orgCollectionsStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(orgCollectionsProc)) return
if (exitCode === 0) root.onOrgCollectionsLoaded(orgCollectionsStdout.text)
else root.formCollectionsLoading = false
}
}
Process {
id: createFolderProc
environment: root.folderEnv()
stdout: StdioCollector { id: createFolderStdout; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(createFolderProc)) return
root.onFolderCreated(exitCode, createFolderStdout.text)
}
}
Process {
id: attachmentProc
environment: root.bwEnv()
stdout: StdioCollector { id: attachmentStdout; waitForEnd: true }
stderr: StdioCollector { id: attachmentStderr; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(attachmentProc)) return
root.onAttachmentDownloaded(exitCode, attachmentStdout.text, attachmentStderr.text)
}
}
Process {
id: listSendsProc
environment: root.bwEnv()
stdout: StdioCollector {
id: listSendsStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(listSendsProc)) return
if (exitCode === 0) root.onSendsLoaded(listSendsStdout.text)
else root.sendsLoading = false
}
}
Process {
id: createSendProc
environment: root.sendEnv(root.sendPayloadJson)
stdout: StdioCollector { id: createSendStdout; waitForEnd: true }
stderr: StdioCollector { id: createSendStderr; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(createSendProc)) return
root.onSendCreated(exitCode, createSendStdout.text, createSendStderr.text)
}
}
Process {
id: deleteSendProc
environment: root.bwEnv()
onExited: function(exitCode) { root.onSendDeleted(exitCode) }
}
Process {
id: generateProc
environment: root.bwEnv()
stdout: StdioCollector { id: generateStdout; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(generateProc)) return
if (root.generateCliStopping) {
root.generateCliStopping = false
var restart = root.currentScreen === "generator" && root.genRegeneratePending
root.genBusy = false
root.genRegeneratePending = false
if (restart) Qt.callLater(root.regenerate)
return
}
root.onGenerated(generateStdout.text, exitCode)
}
}
// The generator server. A managed Process rather than execDetached, so it
// exits with the shell instead of outliving it.
Process {
id: generateServeProc
command: Model.generateServeCommand()
environment: root.generatorServeEnv()
onExited: function(exitCode) {
generateServePoll.stop()
var act = Model.generatorServeExitAction({
stopping: root.generateServeStopping,
wasReady: root.generateServeReady,
busy: root.genBusy,
onGeneratorScreen: root.currentScreen === "generator"
})
root.generateServeStarting = false
root.generateServeReady = false
root.generateServeStopping = false
if (act.giveUp) root.generateServeFailed = true
if (act.dropValue) root.genValue = ""
if (act.useCli) root.regenerateViaCli()
}
}
Process {
id: generateServeRequestProc
stdout: StdioCollector { id: generateServeRequestStdout; waitForEnd: true }
stderr: StdioCollector { id: generateServeRequestStderr; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(generateServeRequestProc)) {
root.resumePendingGeneratorRequest()
return
}
var stopped = root.generateServeRequestStopping
root.generateServeRequestStopping = false
var cb = root.generateServeRequestCallback
root.generateServeRequestCallback = null
if (root.resumePendingGeneratorRequest()) return
if (stopped) return
if (cb) cb(exitCode, generateServeRequestStdout.text, generateServeRequestStderr.text)
}
}
Timer {
id: generateServePoll
property int attempts: 0
interval: 250
repeat: true
onTriggered: {
attempts++
if (attempts > 40) { // 10s, well past bw's usual couple of seconds
stop()
root.generateServeStarting = false
root.generateServeFailed = true
if (root.genBusy) root.regenerateViaCli()
return
}
root.pollGeneratorServe()
}
}
// ---- PIN unlock ----
//
// PIN and master password are handed over in the environment; encrypt-and-store
// and lookup-and-decrypt each run inside one process, so the plaintext never
// travels back through QML on its way to or from the keyring.
Process {
id: pinStoreProc
command: Model.pinStoreCommand()
environment: root.pinEnv(root.pinSetupPin, root.pinSetupMaster)
onExited: function(exitCode) {
root.onPinStored(exitCode)
if (root.logoutPending && root.allCredentialsClearPending)
Qt.callLater(root.requestAllCredentialClear)
}
}
Process {
id: pinUnlockProc
command: Model.pinUnlockCommand()
environment: root.pinEnv(root.pinEntry, "")
stdout: StdioCollector { id: pinUnlockStdout; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(pinUnlockProc)) return
root.onPinUnlockResult(exitCode, pinUnlockStdout.text)
}
}
Process {
id: keyringHasPinProc
command: Model.keyringHasPinCommand()
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.onPinConfiguredChecked(text)
}
}
Process {
id: keyringClearPinProc
command: Model.keyringClearPinCommand()
onExited: function(exitCode) {
if (root.pinClearPending) Qt.callLater(root.requestPinCredentialClear)
}
}
Process {
id: depsCheckProc
command: Model.dependencyCheckCommand()
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.onDependenciesChecked(text)
}
}
// An install runs in a terminal this panel does not own, so there is nothing
// to wait on and no exit code to hear about. Re-probing while the setup
// screen is up is what closes that loop: the moment `bw` lands on PATH the
// screen turns green and onDependenciesChecked moves on to the vault, with
// no second visit to a Re-check button. Only while the panel is open and
// only on that screen, so it costs nothing the rest of the time.
Timer {
id: setupPollTimer
interval: 2500
running: root.opened && root.currentScreen === "setup" && root.setupActionsPending
repeat: true
onTriggered: root.checkDependencies()
}
// The whole first paint now waits behind the dependency probe. If that probe
// never reports -- a shell that will not start, a mangled PATH -- the vault
// should still be reachable instead of the panel sitting on "checking"
// forever, so the status probe goes ahead on its own after a few seconds.
Timer {
id: statusProbeFallbackTimer
interval: 4000
running: !root.statusProbeStarted
repeat: false
onTriggered: {
if (root.statusProbeStarted || root.setupGated) return
// Four seconds of silence from a probe that takes milliseconds means it
// is not coming. Treating that as "checked, nothing missing" is what
// gets past refreshStatus()'s own !depsChecked guard -- an unanswered
// probe must not be the thing that keeps the vault out of reach.
root.depsChecked = true
root.refreshStatus()
}
}
Process {
id: settingWriteProc
stderr: StdioCollector {
id: settingWriteStderr
waitForEnd: true
}
onExited: function(exitCode) {
if (exitCode !== 0) {
root.settingsFlash = ""
root.errorMessage = (settingWriteStderr.text || "").trim() || "Could not save setting to shell.json"
}
}
}
Timer {
id: settingsFlashTimer
interval: 1600
onTriggered: root.settingsFlash = ""
}
Process {
id: keyringHasMasterProc
command: Model.keyringHasMasterPasswordCommand()
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.onFingerprintStoredChecked(text)
}
}
Process {
id: keyringStoreMasterProc
command: Model.keyringStoreMasterPasswordCommand()
environment: root.secretEnv(root.masterToStore)
onExited: function(exitCode) {
root.onMasterPasswordStored(exitCode)
if (root.logoutPending && root.allCredentialsClearPending)
Qt.callLater(root.requestAllCredentialClear)
}
}
Process {
id: keyringLookupMasterProc
command: Model.keyringLookupMasterPasswordCommand()
stdout: StdioCollector {
id: keyringLookupMasterStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(keyringLookupMasterProc)) return
if (exitCode === 0) {
root.onFingerprintPasswordRetrieved(keyringLookupMasterStdout.text)
} else {
root.fingerprintAuthorized = false
root.fingerprintStored = false
root.fingerprintMessage = "Stored master password unavailable. Use your password."
}
}
}
Process {
id: keyringClearMasterProc
command: Model.keyringClearMasterPasswordCommand()
onExited: function(exitCode) {
if (root.masterClearPending) Qt.callLater(root.requestMasterCredentialClear)
}
}
// Logout's clean sweep; see forgetStoredCredentials().
Process {
id: keyringClearAllProc
command: Model.keyringClearAllCommand()
onExited: function(exitCode) {
if (root.allCredentialsClearPending) {
Qt.callLater(root.requestAllCredentialClear)
return
}
root.onLogoutCredentialsFinished(exitCode)
}
}
// ---- Learned associations ----
Process {
id: associationsReadProc
command: Model.associationsReadCommand()
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
if (root.finishScrubRun(associationsReadProc)) return
root.onAssociationsLoaded(text)
}
}
}
Process {
id: associationsWriteProc
command: Model.associationsWriteCommand()
environment: root.associationsEnv()
onExited: function(exitCode) {
if (root.associationsClearPending) {
root.associationsClearPending = false
root.associationsWritePending = false
root.pendingAssociationsJson = ""
associationsClearProc.running = true
return
}
if (exitCode !== 0) {
console.warn("qs-bitwarden-cli: could not save learned suggestions (exit " + exitCode + ")")
}
if (root.associationsWritePending) {
root.associationsWritePending = false
associationsWriteProc.running = true
return
}
root.pendingAssociationsJson = ""
}
}
Process {
id: associationsClearProc
command: Model.associationsClearCommand()
}
PamContext {
id: fingerprintPam
config: "omarchy-lock-fingerprint"
user: root.userName
onCompleted: function(result) {
root.onFingerprintResult(result)
}
onError: function(error) {
root.fingerprintScanning = false
root.fingerprintAuthorized = false
root.fingerprintMessage = "Fingerprint verification unavailable"
}
}
// Polls rather than counting down, for the same reason the auto-lock does:
// a monotonic timer stops while the machine is suspended, and a login left
// pending across a lid close must expire on the time that actually passed.
Timer {
id: pendingLoginTimer
interval: 1000
repeat: true
running: root.secondFactorStartedAt > 0
onTriggered: {
if (!Model.secondFactorWindowOpen(root.secondFactorStartedAt, Date.now())) {
root.abandonAuthSecrets()
}
}
}
Process {
id: loginProc
environment: root.loginProcessEnv()
stdout: StdioCollector {
id: loginStdout
waitForEnd: true
}
stderr: StdioCollector {
id: loginStderr
waitForEnd: true
}
onExited: function(exitCode) {
// A scrub is started from this same handler and claims the process for a
// moment, so a submit arriving in that moment waits on the scrub's exit
// rather than the login's. Returning here without dispatching used to
// drop that submit on the floor -- the click did nothing at all, and the
// one after it worked because by then nothing held the process. That was
// "I had to press Verify twice".
if (root.finishScrubRun(loginProc)) {
if (!root.loginSubmitted) root.resumeDeferredLogin(false)
return
}
if (!root.loginSubmitted) {
root.resumeDeferredLogin(true)
return
}
root.loginSubmitted = false
root.onLoginOutput(loginStdout.text, loginStderr.text, exitCode)
}
}
Process {
id: authPasswordWriterProc
environment: root.authEnv(root.authPasswordWriteValue, "", "", "")
onExited: function(exitCode) { root.onAuthPasswordWriterExited(exitCode) }
}
Process {
id: unlockProc
command: Model.unlockPrewarmCommand()
environment: root.authEnv("", "", "", "")
stdout: StdioCollector {
id: unlockStdout
waitForEnd: true
}
stderr: StdioCollector {
id: unlockStderr
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(unlockProc)) {
if (root.sshAuthSurfaceActive && root.status === "locked") Qt.callLater(root.prepareUnlock)
return
}
if (!root.unlockSubmitted) {
root.clearProcessCollectorSoon(unlockProc)
return
}
root.unlockSubmitted = false
root.onUnlockOutput(unlockStdout.text, unlockStderr.text, exitCode)
}
}
Process {
id: logoutProc
environment: root.bwEnv()
onExited: function(exitCode) { root.onLogoutCliFinished(exitCode) }
}
Process {
id: listProc
environment: root.bwEnv()
stdout: StdioCollector {
id: listStdout
waitForEnd: true
}
stderr: StdioCollector {
id: listStderr
waitForEnd: true
}
onExited: function(exitCode) {
root.onListProcessExited(exitCode, listStdout.text, listStderr.text)
}
}
Process {
id: listOrgsProc
environment: root.bwEnv()
stdout: StdioCollector {
id: listOrgsStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(listOrgsProc)) return
if (exitCode === 0) root.onListOrgsFinished(listOrgsStdout.text)
}
}
Process {
id: getItemProc
environment: root.bwEnv()
stdout: StdioCollector {
id: getItemStdout
waitForEnd: true
}
stderr: StdioCollector {
id: getItemStderr
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(getItemProc)) return
if (exitCode === 0) {
root.onDetailFinished(getItemStdout.text)
} else {
root.isLoading = false
if (!root.vaultReadIsStale("detail")) {
root.errorMessage = String(getItemStderr.text || "").trim() || "Could not load item details"
}
}
}
}
Process {
id: getTotpProc
environment: root.bwEnv()
stdout: StdioCollector {
id: getTotpStdout
waitForEnd: true
}
onExited: function(exitCode) {
if (root.finishScrubRun(getTotpProc)) {
root.continueTotpQueue(true)
return
}
root.onTotpProcessExited(exitCode, getTotpStdout.text)
}
}
Process {
id: copyPasswordProc
environment: root.bwEnv()
stdout: StdioCollector { id: copyPasswordStdout; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(copyPasswordProc)) return
root.onPasswordCopyFinished(exitCode, copyPasswordStdout.text)
}
}
Process {
id: activeWindowProc
command: Model.activeWindowCommand()
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
if (text && text.trim()) {
try {
var data = JSON.parse(text)
root.handleActiveWindowDetected(data)
} catch (e) {
root.suggestedItems = []
root.detectedContext = null
}
}
}
}
}
Process {
id: createItemProc
environment: root.itemEnv()
stdout: StdioCollector { id: createItemStdout; waitForEnd: true }
stderr: StdioCollector { id: createItemStderr; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(createItemProc)) return
root.itemPayloadJson = ""
root.onSaveItemFinished(exitCode, createItemStdout.text, createItemStderr.text)
}
}
Process {
id: editItemProc
environment: root.itemEnv()
stdout: StdioCollector { id: editItemStdout; waitForEnd: true }
stderr: StdioCollector { id: editItemStderr; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(editItemProc)) return
root.itemPayloadJson = ""
root.onSaveItemFinished(exitCode, editItemStdout.text, editItemStderr.text)
}
}
Process {
id: deleteItemProc
environment: root.bwEnv()
stdout: StdioCollector { id: deleteItemStdout; waitForEnd: true }
stderr: StdioCollector { id: deleteItemStderr; waitForEnd: true }
onExited: function(exitCode) {
if (root.finishScrubRun(deleteItemProc)) return
root.onDeleteItemFinished(exitCode, deleteItemStdout.text, deleteItemStderr.text)
}
}
Process {
id: syncProc
environment: root.bwEnv()
onExited: function(exitCode) {
root.onSyncFinished(exitCode)
}
}
Process {
id: lockProc
environment: root.bwEnv()
}
// -------------------------------------------------------------------------
// IPC Handler
// -------------------------------------------------------------------------
IpcHandler {
target: "io.github.elevate08.qs-bitwarden-cli"
function open(): void { root.open() }
function close(): void { root.close() }
function toggle(): void { root.toggle() }
function lock(): string { root.lockVault(); return "locked" }
function settings(): string { root.open(); root.openSettings(); return "settings" }
function setup(): string {
root.open()
root.setupDismissed = false
root.checkDependencies()
root.currentScreen = "setup"
return "setup"
}
function sync(): string { root.syncVault(); return "syncing" }
function status(): string { return root.status }
// Non-secret diagnostics for the SSH agent. No key material, no
// fingerprints, no process paths -- just enough to tell why a signature
// was or was not answered.
function sshAgentStatus(): string {
return JSON.stringify({
enabled: root.sshAgentEnabled,
phase: root.sshAgentPhase,
// Named for what it is: the control channel to the helper is up and
// handshaked. It is not "signing is allowed" -- that is the vault
// state below, and reading this as the former is misleading next to a
// locked vault.
helperChannelOpen: root.sshAgentGateOpen,
vaultState: Model.sshAgentVaultState({
enabled: root.sshAgentEnabled,
helperReady: root.sshAgentGateOpen,
loggedIn: root.status !== "unauthenticated",
unlocked: root.status === "unlocked",
loading: root.sshAgentLoadActive,
hasPublicCache: root.sshAgentKeyCount > 0
}),
setupState: root.sshAgentSetup.state,
// Which binary is actually running, and whether its digest was
// checked. A shipped helper and a silently substituted development
// build behave identically until one of them misbehaves, and without
// these two fields the terminal cannot tell them apart at all.
helperSource: root.sshAgentHelper.source,
helperChecksum: root.sshAgentHelper.checksum,
// Why inspection rejected it, in the inspector's own vocabulary:
// checksum-mismatch, not-elf, wrong-architecture, not-executable,
// self-test-failed. errorCode covers the running helper and stays
// empty for all of these, so without this the terminal is told the
// feature is in error and never told what the error was.
helperState: root.sshAgentHelper.state,
// What the panel believes about client routing: the file it last
// inspected, and whether that produced a notice. Both are read from
// the same state the settings screen draws, so a disagreement between
// this and the screen is itself the answer.
routingFragment: root.uwsmFragment.state,
routingNotice: root.sshRoutingNotice.text !== "",
errorCode: root.sshAgentErrorCode,
keyCount: root.sshAgentKeyCount,
loadActive: root.sshAgentLoadActive,
epoch: root.sshAgentEpoch,
promptShowing: root.sshPrompt !== null,
unlockShowing: root.sshUnlockRequest !== null,
grants: root.sshGrants.length,
screenLocked: root.screenIsLocked,
screenLockAgeMs: root.screenLockCheckedAt > 0 ? Math.round(Date.now() - root.screenLockCheckedAt) : -1,
mayPrompt: root.sshAgentMayPrompt(),
cooldownRefusals: root.sshCooldown ? root.sshCooldown.refusals : 0,
cooldownActive: Model.sshAgentCooldownActive(root.sshCooldown, Date.now())
})
}
}
Component {
id: shieldIconComp
Item {
anchors.fill: parent
// Constant Base Shield
TextMetrics {
id: shieldGlyphMetrics
font.family: root.fontFamily
font.pixelSize: Style.bar.iconFont
text: "󰞀"
}
Text {
textFormat: Text.PlainText
id: shieldGlyph
// The centering below is what holds the glyph on the same logical
// centerline as the bar's panel-open indicator; the renderer does not
// enter into it. Measured at scale 1.3333, QtRendering and
// NativeRendering put the painted center on the same pixel -- but
// QtRendering came out with saturated colour on the glyph edges, blue
// down one side and gold down the other, which no other icon in the bar
// has. So this matches what Omarchy uses everywhere else
// (Ui/OpticalGlyph.qml, Ui/WidgetButton.qml) and the plugin's own lock
// and install badges below.
anchors.centerIn: parent
anchors.horizontalCenterOffset: shieldGlyph.implicitWidth / 2
- (shieldGlyphMetrics.tightBoundingRect.x
+ shieldGlyphMetrics.tightBoundingRect.width / 2)
text: "󰞀"
font.family: root.fontFamily
font.pixelSize: Style.bar.iconFont
color: root.colorizeIcon ? Color.accent : (bar ? bar.barForeground : Color.foreground)
renderType: Text.NativeRendering
}
// Mini Install Badge in the same corner while a required tool is absent.
// A freshly installed widget has to say "click me, there is one step
// left" rather than sit there looking like it failed, so this outranks
// the padlock: with no `bw` there is no lock state worth reporting.
Item {
visible: root.missingRequired.length > 0
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: -Style.space(2)
anchors.bottomMargin: -Style.space(2)
width: Style.space(10)
height: Style.space(10)
Rectangle {
anchors.fill: parent
radius: width / 2
color: bar ? bar.background : Color.background
}
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: "󰐕"
font.family: root.fontFamily
font.pixelSize: Style.space(8)
color: bar ? bar.urgent : Color.urgent
renderType: Text.NativeRendering
}
}
// Mini Padlock Badge in Bottom-Right Corner when locked
Item {
visible: root.status === "locked" && root.missingRequired.length === 0
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.rightMargin: -Style.space(2)
anchors.bottomMargin: -Style.space(2)
width: Style.space(10)
height: Style.space(10)
Rectangle {
anchors.fill: parent
radius: width / 2
color: bar ? bar.background : Color.background
}
Text {
textFormat: Text.PlainText
anchors.centerIn: parent
text: "󰌾"
font.family: root.fontFamily
font.pixelSize: Style.space(8)
color: bar ? bar.barForeground : Color.foreground
renderType: Text.NativeRendering
}
}
}
}
// -------------------------------------------------------------------------
// Status Bar Button
// -------------------------------------------------------------------------
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
iconComponent: shieldIconComp
useActiveColor: false
dimmed: root.status === "unauthenticated" || root.status === "checking"
tooltipText: {
// Ahead of every status: with a required tool missing, whatever `bw`
// last said about the vault is beside the point.
if (root.missingRequired.length > 0) {
return "Bitwarden (Click to finish setup)"
}
if (root.status === "unlocked") {
return "Bitwarden (" + (root.items.length > 0 ? root.items.length + " items" : "Unlocked") + ")"
}
if (root.status === "locked") {
return "Bitwarden (Locked)"
}
return "Bitwarden (Not Logged In)"
}
onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton) {
if (root.status === "unlocked") root.lockVault()
else root.open()
} else if (buttonCode === Qt.MiddleButton) {
root.syncVault()
} else {
root.toggle()
}
}
}
// -------------------------------------------------------------------------
// Popup Window (KeyboardPanel)
// -------------------------------------------------------------------------
SshApprovalPopup {
panel: root
anchorItem: button
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
// Every unlocked screen except the two that are text entry drives the key
// catcher, so arrow navigation works on settings and the generator too.
// Setup is buttons, not text entry, and it is reached with the vault state
// still unknown -- so it takes the key catcher outright rather than
// handing focus to a password field that is not even on screen.
focusTarget: root.currentScreen === "setup"
? keyCatcher
: ((root.status === "unlocked"
&& root.currentScreen !== "edit"
&& root.currentScreen !== "pin"
&& root.currentScreen !== "fingerprint")
? keyCatcher
: (root.status === "unauthenticated"
? (root.show2faField ? code2faField : emailField)
: passField))
contentWidth: panel.fittedContentWidth(Style.space(450))
contentHeight: panel.fittedContentHeight(mainColumn.implicitHeight, Style.space(640) + root.filterDrawerHeight)
// PanelKeyCatcher maps h/j/k/l to arrow navigation and consumes them before
// its textKey signal fires, which silently swallowed the l (lock) shortcut.
// Forwarding here first gives our letter bindings the first look; anything
// we do not accept falls through to the catcher's own navigation.
Item {
id: shortcutInterceptor
Keys.onPressed: function(event) {
// Escape is handled here rather than in the key catcher because the
// catcher is blocked on every screen built around a text field -- the
// item form, the PIN and fingerprint screens, the Send composer --
// and a blocked catcher swallows Escape along with everything else.
// This interceptor runs first and is not gated by `blocked`, so
// cancelling out of a form works while the cursor is in a field.
if (event.key === Qt.Key_Escape && !(event.modifiers & ~Qt.KeypadModifier)) {
root.handleEscape()
event.accepted = true
return
}
// Alt may arrive with no text depending on the keymap, so fall back to
// the key code for A-Z.
var t = event.text ? String(event.text).toLowerCase() : ""
if (!t && event.key >= Qt.Key_A && event.key <= Qt.Key_Z) {
t = String.fromCharCode(event.key).toLowerCase()
}
if (event.modifiers & Qt.AltModifier) {
if (t && root.status === "unlocked" && root.runAltShortcut(t)) event.accepted = true
return
}
if (event.modifiers & ~Qt.KeypadModifier) return
if (!t || root.currentScreen !== "main") return
if (root.openFilterGroup !== "") return
if (t !== "h" && t !== "j" && t !== "k" && t !== "l") return
if (root.runShortcut(t)) event.accepted = true
}
}
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
Keys.forwardTo: [shortcutInterceptor]
blocked: searchField.activeFocus
|| emailField.activeFocus
|| loginPassField.activeFocus
|| code2faField.activeFocus
|| passField.activeFocus
|| pinField.activeFocus
|| (root.currentScreen === "edit")
|| (root.currentScreen === "pin")
|| (root.currentScreen === "fingerprint")
|| (root.currentScreen === "sends" && root.sendMode === "create")
// Reached only on screens where the catcher is not blocked; the
// interceptor handles Escape everywhere else. Same dispatch either way.
onCloseRequested: root.handleEscape()
onTabRequested: function(direction) {
if (root.currentScreen === "main") {
root.cycleCategory(direction)
} else {
root.switchPanel(direction)
}
}
onMoveRequested: function(dx, dy) {
if (root.currentScreen === "sends" && root.sendMode === "list") {
if (dy !== 0) root.moveSendCursor(dy)
return
}
if (root.currentScreen === "settings") {
if (dy !== 0) root.moveSettingsCursor(dy)
else if (dx !== 0) root.adjustSetting(dx)
return
}
// While a filter drawer is open the arrows drive it, not the item list.
if (root.openFilterGroup !== "" && root.currentScreen === "main") {
if (dy !== 0) root.moveFilterCursor(dy)
return
}
if (!root.cursorActive) {
root.cursorActive = true
return
}
if (root.currentScreen === "main") {
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) root.cycleCategory(dx)
}
}
onActivateRequested: {
if (root.currentScreen === "generator" && root.generatorFeedsForm) {
root.useGeneratedPassword()
return
}
if (root.currentScreen === "sends" && root.sendMode === "list") {
if (root.sendIndex < root.sends.length) root.copySendLink(root.sends[root.sendIndex])
return
}
if (root.currentScreen === "settings") {
root.activateSettingRow()
return
}
if (root.openFilterGroup !== "" && root.currentScreen === "main") {
root.activateFilterOption()
return
}
if (root.currentScreen === "main") {
var item = root.getSelectedItem()
if (item) {
root.handleSmartEnter(item)
}
return
}
// The password row has always been labelled "Copy password (y / Enter)"
// and the detail screen has never handled Enter, so that half of the
// tooltip was a promise nothing kept. Enter copies the item's primary
// secret here, the same one `y` reaches: the password on a login, the
// number on a card. A note or an identity has no single such value, so
// Enter stays inert on those rather than guessing at one.
if (root.currentScreen === "detail") {
if (root.detailIsCard) {
if (root.detailCard && root.detailCard.number) {
root.copyToClipboard(root.detailCard.number, "Card number")
}
} else if (root.detailIsLoginLike && root.detailPassword) {
root.copyToClipboard(root.detailPassword, "Password")
}
}
}
onTextKey: function(key) {
var lower = String(key).toLowerCase()
if (root.currentScreen === "sends" && root.sendMode === "list") {
if (lower === "n") root.beginCreateSend()
else if (lower === "r") root.loadSends()
else if (lower === "x" && root.sendIndex < root.sends.length) root.deleteSend(root.sends[root.sendIndex])
return
}
if (root.currentScreen === "main") {
if (lower === "/") searchField.forceActiveFocus()
else root.runShortcut(lower)
} else if (root.currentScreen === "detail") {
// `y` is "copy the thing this item is for". On a login that is the
// password; on a card it is the number. Keeping one key for the
// primary secret is worth more than a key that means `password`
// everywhere and does nothing on two of the four types.
if (lower === "y" || lower === "p") {
if (root.detailIsCard) {
if (root.detailCard && root.detailCard.number) root.copyToClipboard(root.detailCard.number, "Card number")
} else if (root.detailPassword) {
root.copyToClipboard(root.detailPassword, "Password")
}
} else if (lower === "n") {
if (root.detailIsCard && root.detailCard && root.detailCard.number) {
root.copyToClipboard(root.detailCard.number, "Card number")
}
} else if (lower === "k") {
if (root.detailIsCard && root.detailCard && root.detailCard.code) {
root.copyToClipboard(root.detailCard.code, "Security code")
}
} else if (lower === "u" || lower === "c") {
// `u` copies the identifier, `c` the contact address. On a login
// both land on the one username field, which is what they have
// always done.
if (root.detailIsIdentity && root.detailIdentity) {
if (lower === "c" && root.detailIdentity.email) {
root.copyToClipboard(root.detailIdentity.email, "Email")
} else if (root.detailIdentity.username) {
root.copyToClipboard(root.detailIdentity.username, "Username")
}
} else if (root.detailItem && root.detailItem.username) {
root.copyToClipboard(root.detailItem.username, "Username")
}
} else if (lower === "m") {
if (root.liveTotp) root.copyToClipboard(root.liveTotp, "TOTP")
} else if (lower === "e") {
if (root.detailItem) root.startEditItem(root.detailItem)
} else if (lower === "x") {
if (root.detailItem && root.detailItem.typeCode !== 5) root.showDeleteConfirm = true
} else if (lower === "v") {
if (root.primaryRevealKey !== "") root.toggleFieldReveal(root.primaryRevealKey)
} else if (lower === "a") {
root.saveAllAttachments()
} else if (lower === "b" || lower === "q") {
root.currentScreen = "main"
}
}
}
Column {
id: mainColumn
anchors.fill: parent
spacing: Style.space(12)
// -------------------------------------------------------------------
// Hero Header
// -------------------------------------------------------------------
PanelHero {
width: parent.width
title: "Bitwarden"
meta: {
if (root.status === "unlocked") {
if (root.isSyncing) return "Syncing..."
if (root.isLoading && root.items.length === 0) return "Loading items..."
// The email arrives with `bw status`, which lags the item list on
// a cold start and after a terminal-login handoff. Fall back to
// the count so the subtitle is never blank in that gap.
return root.userEmail || (root.filteredItems.length + " items")
}
if (root.status === "locked") return "Vault Locked"
if (root.status === "checking") return "Checking status..."
return "Log In"
}
foreground: root.fg
fontFamily: root.fontFamily
iconComponent: Text {
textFormat: Text.PlainText
text: "󰞀"
color: root.barIconColor
font.family: root.fontFamily
font.pixelSize: Style.font.display
}
trailingControl: Row {
spacing: Style.space(6)
// New Item Button
PanelActionButton {
visible: root.status === "unlocked" && root.activeScreen === "main"
iconText: "󰐕"
tooltipText: "New item (n)"
fontFamily: root.fontFamily
onClicked: root.startAddNewItem()
}
// Sync Vault Button
PanelActionButton {
visible: root.status === "unlocked"
iconText: "󰑐"
tooltipText: root.isSyncing ? "Syncing..." : "Sync vault (r)"
fontFamily: root.fontFamily
enabled: !root.isSyncing
onClicked: root.syncVault()
}
// Send Button
PanelActionButton {
visible: root.status === "unlocked" && root.activeScreen !== "sends"
iconText: "󰒗"
tooltipText: "Bitwarden Send (Alt+S)"
fontFamily: root.fontFamily
onClicked: root.openSends()
}
// Generator Button
PanelActionButton {
visible: root.status === "unlocked" && root.activeScreen !== "generator"
iconText: "󰌆"
tooltipText: "Password generator (g)"
fontFamily: root.fontFamily
onClicked: root.openGenerator()
}
// Settings Button
PanelActionButton {
visible: root.activeScreen !== "settings" && root.activeScreen !== "setup" && root.activeScreen !== "pin"
iconText: "󰒓"
tooltipText: "Settings (s)"
fontFamily: root.fontFamily
onClicked: root.openSettings()
}
// Lock Vault Button
PanelActionButton {
visible: root.status === "unlocked"
iconText: "󰌾"
tooltipText: "Lock vault (l)"
fontFamily: root.fontFamily
onClicked: root.lockVault()
}
// Close Panel Button
PanelActionButton {
iconText: "󰅖"
tooltipText: "Close (Esc)"
fontFamily: root.fontFamily
onClicked: root.close()
}
}
}
// -------------------------------------------------------------------
// Sequential TOTP Follow-Up Action Banner
// -------------------------------------------------------------------
BorderSurface {
visible: root.totpFollowupActive && root.totpFollowupItem !== null
width: parent.width
implicitHeight: Style.space(42)
color: Util.alpha(Color.accent, 0.2)
radius: Style.cornerRadius
borderSpec: Border.surfaceSpec("menu", "border", Color.accent, 1)
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: "󰄬"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - copyFollowupTotpBtn.width - Style.space(40)
spacing: 1
Text {
textFormat: Text.PlainText
text: "Password copied! Press Enter for TOTP"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
}
Text {
textFormat: Text.PlainText
text: root.totpFollowupCode ? ("Code: " + root.totpFollowupCode + " (expires in " + root.totpSecRemaining + "s)") : "Fetching 2FA code..."
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Button {
id: copyFollowupTotpBtn
anchors.verticalCenter: parent.verticalCenter
text: "Copy TOTP (Enter)"
selected: true
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: {
if (root.totpFollowupItem) root.copyTotpCode(root.totpFollowupItem)
root.totpFollowupActive = false
}
}
}
}
// -------------------------------------------------------------------
// Development Helper Banner
// -------------------------------------------------------------------
// The shipped helper is what a user installed and what CI verified.
// Falling back to a local build is deliberate -- a broken release must
// not strand a working one -- but it is a state you can sit in for
// days without noticing, signing with a binary nobody checked. The
// settings screen says so in passing; this says so wherever you are.
BorderSurface {
visible: root.sshAgentHelper.source === "development" && root.activeScreen !== "settings"
width: parent.width
implicitHeight: sshDevHelperText.implicitHeight + Style.space(12)
color: Util.alpha(Color.urgent, 0.15)
radius: Style.cornerRadius
borderSpec: Border.surfaceSpec("menu", "border", Color.urgent, 1)
Row {
anchors.centerIn: parent
width: parent.width - Style.space(16)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
text: "󰀪"
color: Color.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
id: sshDevHelperText
text: Model.sshAgentDevelopmentHelperWarning(root.sshAgentHelper)
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
width: parent.width - Style.space(24)
}
}
}
// -------------------------------------------------------------------
// SSH Signing Cooldown Banner
// -------------------------------------------------------------------
// A five-minute signing outage is not noticed on the SSH agent
// settings screen: the requests it refuses arrive while the panel is
// showing something else, or while the vault is locked and no prompt
// can be raised at all. So the explanation lives on every screen,
// and carries the only control that ends the cooldown early -- an
// approval cannot, because there is no prompt left to approve.
BorderSurface {
visible: root.sshCooldownStatus.active
width: parent.width
implicitHeight: sshCooldownBannerBody.implicitHeight + Style.space(12)
color: Util.alpha(Color.urgent, 0.15)
radius: Style.cornerRadius
borderSpec: Border.surfaceSpec("menu", "border", Color.urgent, 1)
Row {
anchors.centerIn: parent
width: parent.width - Style.space(16)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
text: "󰀪"
color: Color.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Column {
id: sshCooldownBannerBody
width: parent.width - Style.space(24)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
id: sshCooldownBannerText
text: root.sshCooldownStatus.message
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
width: parent.width
}
Button {
text: "Resume Signing Now"
iconText: "󰐊"
tooltipText: "End the cooldown; the next signing request asks again"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.resumeSshSigning()
}
}
}
}
// -------------------------------------------------------------------
// SCREEN 0f: BITWARDEN SEND
// -------------------------------------------------------------------
Flickable {
id: sendFlick
visible: root.activeScreen === "sends"
width: parent.width
height: Math.min(Style.space(520), sendCol.implicitHeight)
contentWidth: width
contentHeight: sendCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: sendFlick }
Column {
id: sendCol
width: sendFlick.width - root.scrollGutter
spacing: Style.space(10)
PanelSeparator { width: parent.width }
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: root.sendMode === "create" ? "Back to Sends" : "Back (Esc)"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: {
if (root.sendMode === "create") { root.sendError = ""; root.sendMode = "list" }
else root.currentScreen = "main"
}
}
Button {
visible: root.sendMode === "list"
text: "New Send"
iconText: "󰐕"
selected: true
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.beginCreateSend()
}
Button {
visible: root.sendMode === "list"
text: "Refresh"
iconText: "󰑐"
iconSpinning: root.sendsLoading
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.loadSends()
}
}
Text {
textFormat: Text.PlainText
visible: root.sendError !== ""
width: parent.width
text: root.sendError
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
// ---------------- list ----------------
Column {
visible: root.sendMode === "list"
width: parent.width
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
visible: !root.sendsLoading && root.sends.length === 0
width: parent.width
text: "No Sends yet. A Send shares a secret through a link that expires on its own -- useful for handing someone a credential without it living in a chat log."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Text {
textFormat: Text.PlainText
visible: root.sendsLoading
text: "Loading Sends..."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Repeater {
model: root.sends
delegate: BorderSurface {
required property var modelData
required property int index
width: parent.width
implicitHeight: sendRowCol.implicitHeight + Style.space(16)
radius: Style.cornerRadius
readonly property bool cursored: index === root.sendIndex
color: cursored ? Style.hoverFillFor(root.fg, Color.accent) : "transparent"
borderSpec: Border.surfaceSpec("menu", "border",
cursored ? Color.accent : Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.18), 1)
MouseArea {
anchors.fill: parent
hoverEnabled: true
onEntered: root.sendIndex = index
}
Row {
anchors.fill: parent
anchors.margins: Style.space(8)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: modelData.isFile ? "󰈤" : "󰈙"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
}
Column {
id: sendRowCol
width: parent.width - Style.space(110)
spacing: Style.space(2)
Text {
textFormat: Text.PlainText
width: parent.width
text: modelData.name
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
elide: Text.ElideRight
}
Row {
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: Model.sendExpiryLabel(modelData, Date.now())
color: Model.sendExpiryLabel(modelData, Date.now()) === "expired" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
textFormat: Text.PlainText
text: "\u00b7 " + Model.sendAccessLabel(modelData)
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
textFormat: Text.PlainText
visible: modelData.passwordSet
text: "\u00b7 󰌾 password"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
}
PanelActionButton {
anchors.verticalCenter: parent.verticalCenter
iconText: "󰆏"
tooltipText: "Copy Send link"
fontFamily: root.fontFamily
onClicked: root.copySendLink(modelData)
}
PanelActionButton {
anchors.verticalCenter: parent.verticalCenter
iconText: "󰆴"
tooltipText: "Delete this Send"
fontFamily: root.fontFamily
enabled: !root.sendBusy
onClicked: root.deleteSend(modelData)
}
}
}
}
}
// ---------------- create ----------------
Column {
visible: root.sendMode === "create"
width: parent.width
spacing: Style.space(8)
Text { textFormat: Text.PlainText; text: "NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
id: sendNameField
width: parent.width
placeholderText: "What is this? (optional)"
text: root.sendFormName
onTextChanged: root.sendFormName = text
enabled: !root.sendBusy
}
Text { textFormat: Text.PlainText; text: "TEXT TO SEND"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "The secret to share..."
text: root.sendFormText
onTextChanged: root.sendFormText = text
enabled: !root.sendBusy
}
Row {
width: parent.width
spacing: Style.space(6)
Button {
text: "Hide text by default"
tooltipText: "The recipient must click to reveal it"
selected: root.sendFormHidden
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.sendFormHidden = !root.sendFormHidden
}
}
Row {
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(170)
text: "Delete after"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: "days"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
NumberField {
anchors.verticalCenter: parent.verticalCenter
value: root.sendFormDays
from: 1
to: 31
stepSize: 1
foreground: root.fg
accent: Color.accent
fontFamily: root.fontFamily
onModified: function(v) { root.sendFormDays = v }
}
}
Row {
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(170)
text: "Maximum views"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.sendFormMaxAccess === 0 ? "unlimited" : ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
NumberField {
anchors.verticalCenter: parent.verticalCenter
value: root.sendFormMaxAccess
from: 0
to: 100
stepSize: 1
foreground: root.fg
accent: Color.accent
fontFamily: root.fontFamily
onModified: function(v) { root.sendFormMaxAccess = v }
}
}
Text { textFormat: Text.PlainText; text: "PASSWORD (OPTIONAL)"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "Recipient must enter this to open the Send..."
password: true
text: root.sendFormPassword
onTextChanged: root.sendFormPassword = text
enabled: !root.sendBusy
}
Button {
width: parent.width
text: root.sendBusy ? "Creating..." : "Create Send & Copy Link"
iconText: root.sendBusy ? "󰑐" : "󰒗"
iconSpinning: root.sendBusy
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.sendBusy
onClicked: root.submitCreateSend()
}
}
}
}
// -------------------------------------------------------------------
// SCREEN 0e: FINGERPRINT SETUP
// -------------------------------------------------------------------
Flickable {
id: fpFlick
visible: root.activeScreen === "fingerprint"
width: parent.width
height: Math.min(Style.space(520), fpCol.implicitHeight)
contentWidth: width
contentHeight: fpCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: fpFlick }
Column {
id: fpCol
width: fpFlick.width - root.scrollGutter
spacing: Style.space(12)
PanelSeparator { width: parent.width }
Column {
width: parent.width
spacing: Style.space(4)
Text {
textFormat: Text.PlainText
text: "Enable fingerprint unlock"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
}
Text {
textFormat: Text.PlainText
width: parent.width
text: "A fingerprint proves you are present but cannot produce your master password, and bw unlock accepts nothing else. The password is stored in the OS login keyring, and a verified fingerprint is the gate on reading it back."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Text {
textFormat: Text.PlainText
width: parent.width
text: "Anyone who can read your unlocked login keyring can read the password. A PIN stores it encrypted instead."
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
Column {
width: parent.width
spacing: Style.space(8)
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
id: fpMasterField
width: parent.width
placeholderText: "Needed once, to store for fingerprint unlock..."
password: true
text: root.fpSetupMaster
onTextChanged: root.fpSetupMaster = text
onAccepted: root.submitFingerprintSetup()
enabled: !root.fpBusy
}
Text {
textFormat: Text.PlainText
visible: root.fpError !== ""
width: parent.width
text: root.fpError
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: root.fpBusy ? "Saving..." : "Enable"
iconText: root.fpBusy ? "󰑐" : "󰈷"
iconSpinning: root.fpBusy
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.fpBusy
onClicked: root.submitFingerprintSetup()
}
Button {
text: "Cancel"
iconText: "󰅖"
fontFamily: root.fontFamily
enabled: !root.fpBusy
onClicked: { root.fpError = ""; root.currentScreen = "settings" }
}
}
}
}
}
// -------------------------------------------------------------------
// SCREEN 0c: PIN SETUP
// -------------------------------------------------------------------
// -------------------------------------------------------------------
// SCREEN 0d: GENERATOR
// -------------------------------------------------------------------
Flickable {
id: genFlick
visible: root.activeScreen === "generator"
width: parent.width
height: Math.min(Style.space(520), genCol.implicitHeight)
contentWidth: width
contentHeight: genCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: genFlick }
Column {
id: genCol
width: genFlick.width - root.scrollGutter
spacing: Style.space(10)
PanelSeparator { width: parent.width }
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: root.generatorFeedsForm ? "Back to item (Esc)" : "Back (Esc)"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.closeGenerator()
}
// Only when the generator was opened from the item form: hand
// the value back to the password field and return there.
Button {
visible: root.generatorFeedsForm
text: "Use this password (Enter)"
iconText: "󰄬"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
selected: true
accent: Color.accent
enabled: !root.genBusy && root.genValue !== ""
onClicked: root.useGeneratedPassword()
}
}
// Generated value
BorderSurface {
width: parent.width
implicitHeight: Style.space(58)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(6)
spacing: Style.space(4)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(90)
text: root.genBusy ? "Generating..." : (root.genValue || "-")
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
font.bold: true
wrapMode: Text.WrapAnywhere
maximumLineCount: 2
elide: Text.ElideRight
}
PanelActionButton {
anchors.verticalCenter: parent.verticalCenter
iconText: "󰑐"
tooltipText: "Regenerate"
fontFamily: root.fontFamily
enabled: !root.genBusy
onClicked: root.regenerate()
}
PanelActionButton {
anchors.verticalCenter: parent.verticalCenter
iconText: "󰆏"
tooltipText: "Copy"
fontFamily: root.fontFamily
enabled: !root.genBusy && root.genValue !== ""
onClicked: root.copyGenerated()
}
}
}
// Strength meter
Column {
width: parent.width
spacing: Style.space(3)
readonly property var strength: Model.generatorStrength(root.genOpts)
Row {
width: parent.width
Text {
textFormat: Text.PlainText
text: parent.parent.strength.label
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Item { width: Style.space(6); height: 1 }
Text {
textFormat: Text.PlainText
text: "~" + parent.parent.strength.bits + " bits of entropy"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Rectangle {
width: parent.width
height: Style.space(4)
radius: height / 2
color: Qt.rgba(root.fg.r, root.fg.g, root.fg.b, 0.15)
Rectangle {
width: parent.width * parent.parent.strength.fraction
height: parent.height
radius: height / 2
color: Color.accent
}
}
}
PanelSeparator { width: parent.width }
// Type
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: "Password"
iconText: "󰌆"
selected: root.genOpts.type === "password"
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("type", "password")
}
Button {
text: "Passphrase"
iconText: "󰈚"
selected: root.genOpts.type === "passphrase"
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("type", "passphrase")
}
}
// ---- Password options ----
Column {
visible: root.genOpts.type === "password"
width: parent.width
spacing: Style.space(8)
Row {
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(170)
text: "Length"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
NumberField {
anchors.verticalCenter: parent.verticalCenter
value: root.genOpts.length
from: 5
to: 128
stepSize: 1
foreground: root.fg
accent: Color.accent
fontFamily: root.fontFamily
onModified: function(v) { root.setGenOpt("length", v) }
}
}
Flow {
width: parent.width
spacing: Style.space(6)
Button {
text: "A-Z"
selected: root.genOpts.uppercase
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("uppercase", !root.genOpts.uppercase)
}
Button {
text: "a-z"
selected: root.genOpts.lowercase
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("lowercase", !root.genOpts.lowercase)
}
Button {
text: "0-9"
selected: root.genOpts.numbers
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("numbers", !root.genOpts.numbers)
}
Button {
text: "!@#$%^&*"
selected: root.genOpts.special
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("special", !root.genOpts.special)
}
Button {
text: "Avoid ambiguous"
tooltipText: "Exclude characters that are easy to confuse, such as l, 1, I, O and 0"
selected: root.genOpts.ambiguous
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("ambiguous", !root.genOpts.ambiguous)
}
}
Row {
visible: root.genOpts.numbers
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(170)
text: "Minimum numbers"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
NumberField {
anchors.verticalCenter: parent.verticalCenter
value: root.genOpts.minNumber
from: 0
to: 9
stepSize: 1
foreground: root.fg
accent: Color.accent
fontFamily: root.fontFamily
onModified: function(v) { root.setGenOpt("minNumber", v) }
}
}
Row {
visible: root.genOpts.special
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(170)
text: "Minimum special"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
NumberField {
anchors.verticalCenter: parent.verticalCenter
value: root.genOpts.minSpecial
from: 0
to: 9
stepSize: 1
foreground: root.fg
accent: Color.accent
fontFamily: root.fontFamily
onModified: function(v) { root.setGenOpt("minSpecial", v) }
}
}
}
// ---- Passphrase options ----
Column {
visible: root.genOpts.type === "passphrase"
width: parent.width
spacing: Style.space(8)
Row {
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(170)
text: "Number of words"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
NumberField {
anchors.verticalCenter: parent.verticalCenter
value: root.genOpts.words
from: 3
to: 20
stepSize: 1
foreground: root.fg
accent: Color.accent
fontFamily: root.fontFamily
onModified: function(v) { root.setGenOpt("words", v) }
}
}
Row {
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(170)
text: "Word separator"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
TextField {
anchors.verticalCenter: parent.verticalCenter
width: Style.space(90)
text: root.genOpts.separator
onTextChanged: if (text && text !== root.genOpts.separator) root.setGenOpt("separator", text.charAt(0))
}
}
Flow {
width: parent.width
spacing: Style.space(6)
Button {
text: "Capitalize"
selected: root.genOpts.capitalize
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("capitalize", !root.genOpts.capitalize)
}
Button {
text: "Include number"
selected: root.genOpts.includeNumber
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.setGenOpt("includeNumber", !root.genOpts.includeNumber)
}
}
}
}
}
// Scrolls rather than overflowing the panel: this screen is taller
// than the popup's height cap on smaller displays.
Flickable {
id: pinFlick
visible: root.activeScreen === "pin"
width: parent.width
height: Math.min(Style.space(520), pinCol.implicitHeight)
contentWidth: width
contentHeight: pinCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: pinFlick }
Column {
id: pinCol
width: pinFlick.width - root.scrollGutter
spacing: Style.space(12)
PanelSeparator { width: parent.width }
Column {
width: parent.width
spacing: Style.space(4)
Text {
textFormat: Text.PlainText
text: "Set an unlock PIN"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
}
Text {
textFormat: Text.PlainText
width: parent.width
text: "Your master password is encrypted with a key derived from this PIN, and only the encrypted form is stored. "
+ "Use " + Model.pinRecommendedLength() + " digits or more; " + Model.pinMinLength()
+ " is the floor, and every extra digit multiplies an attacker's work by ten."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
}
Column {
width: parent.width
spacing: Style.space(8)
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "Needed once, to encrypt the PIN..."
password: true
text: root.pinSetupMaster
onTextChanged: root.pinSetupMaster = text
enabled: !root.pinBusy
}
Text {
textFormat: Text.PlainText
text: "PIN"
// The label turns with the field, so the warning is visible even
// when the cursor has moved on to Confirm.
color: root.pinSetupWeak ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
TextField {
id: pinSetupPinField
width: parent.width
placeholderText: Model.pinRecommendedLength() + " digits or more..."
password: true
text: root.pinSetupPin
onTextChanged: root.pinSetupPin = text.replace(/[^0-9]/g, "")
enabled: !root.pinBusy
// A short PIN is allowed but not waved through: the border goes
// red rather than accent while it is under the recommendation.
accent: root.pinSetupWeak ? root.urgent : Color.accent
foreground: root.pinSetupWeak ? root.urgent : root.fg
}
Text {
textFormat: Text.PlainText
visible: root.pinSetupWeak
width: parent.width
text: "󰀪 " + Model.pinWeakWarning(root.pinSetupPin)
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Text { textFormat: Text.PlainText; text: "CONFIRM PIN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "Repeat the PIN..."
password: true
text: root.pinSetupConfirm
onTextChanged: root.pinSetupConfirm = text.replace(/[^0-9]/g, "")
onAccepted: root.submitPinSetup()
enabled: !root.pinBusy
}
Text {
textFormat: Text.PlainText
visible: root.pinError !== ""
width: parent.width
text: root.pinError
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: root.pinBusy ? "Encrypting..." : "Save PIN"
iconText: root.pinBusy ? "󰑐" : "󰄬"
iconSpinning: root.pinBusy
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.pinBusy
onClicked: root.submitPinSetup()
}
Button {
text: "Cancel"
iconText: "󰅖"
fontFamily: root.fontFamily
enabled: !root.pinBusy
onClicked: { root.pinError = ""; root.currentScreen = "settings" }
}
}
}
}
}
// -------------------------------------------------------------------
// SCREEN 0a: SETUP WIZARD (missing dependencies)
// -------------------------------------------------------------------
// Scrolls rather than overflowing the panel: this screen is taller
// than the popup's height cap on smaller displays.
Flickable {
id: setupFlick
visible: root.activeScreen === "setup"
width: parent.width
height: Math.min(Style.space(520), setupCol.implicitHeight)
contentWidth: width
contentHeight: setupCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: setupFlick }
Column {
id: setupCol
width: setupFlick.width - root.scrollGutter
spacing: Style.space(12)
PanelSeparator { width: parent.width }
Column {
width: parent.width
spacing: Style.space(4)
Text {
textFormat: Text.PlainText
text: root.missingRequired.length > 0 ? "One more step" : "All set"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
}
Text {
textFormat: Text.PlainText
width: parent.width
text: root.missingRequired.length > 0
? "The plugin drives these tools rather than bundling them. Install the required ones below and the panel picks them up on its own -- no terminal work to come back from."
: "Every required tool is installed. Optional ones below unlock extra features."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
}
Repeater {
// Only rows this machine can act on. A desktop with no fingerprint
// reader is not missing a dependency.
model: Model.applicableDependencies(root.dependencies)
delegate: BorderSurface {
required property var modelData
width: parent.width
implicitHeight: depRow.implicitHeight + Style.space(16)
radius: Style.cornerRadius
color: modelData.ready ? "transparent" : Util.alpha(root.urgent, 0.12)
borderSpec: Border.surfaceSpec("menu", "border",
modelData.ready ? Color.accent : root.urgent, 1)
Row {
id: depRow
anchors.fill: parent
anchors.margins: Style.space(8)
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: modelData.ready ? "󰄬" : (modelData.required ? "󰅖" : "󰋗")
color: modelData.ready ? Color.accent : (modelData.required ? root.urgent : root.dim)
font.family: root.fontFamily
font.pixelSize: Style.font.subtitle
}
Column {
width: parent.width - Style.space(170)
spacing: Style.space(2)
Row {
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: modelData.label
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: modelData.required ? "required" : "optional"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Text {
textFormat: Text.PlainText
width: parent.width
text: modelData.purpose
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Text {
textFormat: Text.PlainText
visible: !!modelData.note
width: parent.width
text: modelData.note
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// The package being on PATH is not the finish line for a
// setup row: fingerprint unlock also wants an enrolled
// finger and the PAM stack, and only the setup command
// produces those.
Text {
textFormat: Text.PlainText
visible: modelData.setup && modelData.installed && !modelData.ready
width: parent.width
text: "Reader stack is installed, but no finger is enrolled yet."
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
// One button per row, whichever door this row goes through.
Button {
anchors.verticalCenter: parent.verticalCenter
visible: modelData.setup ? !modelData.ready : !modelData.installed
text: modelData.setup ? "Set up" : "Install"
iconText: modelData.setup ? "󰈷" : "󰐕"
tooltipText: modelData.setup
? "omarchy setup security fingerprint"
: "omarchy install app " + modelData.pkg
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.installOne(modelData)
}
}
}
}
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: "Re-check"
iconText: "󰑐"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.checkDependencies()
}
// The one button a first run needs. It covers the optional tools
// too, so a single trip through the terminal leaves every feature
// working rather than only the ones that block startup.
Button {
visible: root.installablePackages.length > 0
text: root.installablePackages.length > 1 ? "Install all missing" : "Install"
iconText: "󰐕"
selected: true
accent: Color.accent
tooltipText: "omarchy install app " + root.installablePackages.join(" ")
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.installMissing()
}
Button {
text: root.missingRequired.length > 0 ? "Continue anyway" : "Done"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.dismissSetup()
}
}
}
}
// -------------------------------------------------------------------
// SCREEN 0b: SETTINGS
// -------------------------------------------------------------------
// Scrolls rather than overflowing the panel: this screen is taller
// than the popup's height cap on smaller displays.
Column {
id: settingsScreen
visible: root.activeScreen === "settings"
width: parent.width
spacing: Style.space(10)
PanelSeparator { width: parent.width }
// Pinned above the scroll area rather than scrolling with it. The
// right half is the way out, which should never require scrolling to
// find. The left half is the section the view is currently inside,
// and it folds that section -- so a user twenty rows into Security
// can shut it without first scrolling back to its heading.
Item {
width: parent.width
height: Style.space(26)
// An indicator, not a control. It says which section the view is
// inside; the heading it stands for is a plain heading too.
Row {
id: stickySection
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(6)
visible: root.settingsStickyEntry !== null
PanelSectionHeader {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.settingsStickyEntry
? String(root.settingsStickyEntry.label || "").toUpperCase() : ""
foreground: root.fg
fontFamily: root.fontFamily
}
}
Row {
anchors.right: parent.right
// Flush with the scrolling rows below, which stop short of the
// scrollbar. Without this the Back button overhangs every
// control it sits above.
anchors.rightMargin: root.scrollGutter
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
visible: root.settingsFlash !== ""
text: "󰄬 " + root.settingsFlash
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Button {
text: "Back (Esc)"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.closeSettings()
}
}
}
Flickable {
id: settingsFlick
width: parent.width
height: Math.min(Style.space(520), settingsCol.implicitHeight)
contentWidth: width
contentHeight: settingsCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar {
id: settingsScrollBar
policy: ScrollBar.AsNeeded
}
WheelScroll { view: settingsFlick }
// The pinned bar names the section the view is inside, so it has to
// be recomputed as the view moves and whenever the content resizes.
// The height case runs a frame later, after layout.
onContentYChanged: root.updateSettingsSticky()
onContentHeightChanged: Qt.callLater(root.updateSettingsSticky)
Column {
id: settingsCol
// Short of the scrollbar rather than under it. The bar is an
// overlay, so without this it sits on top of whatever is at the
// right edge -- which on this screen is every toggle and every
// number field. Reserved unconditionally: the width would
// otherwise change as the bar came and went, reflowing the rows
// underneath it.
width: settingsFlick.width - root.scrollGutter
spacing: Style.space(10)
Connections {
target: root
function onSettingsIndexChanged() {
var row = settingsRepeater.itemAt(root.settingsIndex)
if (!row) return
if (row.y < settingsFlick.contentY) {
settingsFlick.contentY = Math.max(0, row.y - Style.space(8))
} else if (row.y + row.height > settingsFlick.contentY + settingsFlick.height) {
settingsFlick.contentY = Math.min(
Math.max(0, settingsFlick.contentHeight - settingsFlick.height),
row.y + row.height - settingsFlick.height + Style.space(8))
}
}
}
Repeater {
id: settingsRepeater
model: root.settingsEntries
delegate: Column {
required property var modelData
required property int index
width: parent.width
spacing: Style.space(4)
readonly property bool cursored: index === root.settingsIndex
readonly property bool isGroup: modelData.kind === "group"
// This heading is the one the pinned bar is currently drawing.
// The bar stands in for it completely, so the row gives up its
// space rather than sitting there empty -- a transparent row
// left a heading-sized hole directly under the bar.
//
// Exactly one heading is ever in this state, so the content
// height does not change as the pinned section changes: the
// heading taking over collapses at the same moment the previous
// one is restored, and the view does not jump.
readonly property bool yieldsToBar: isGroup
&& Boolean(root.settingsStickyEntry)
&& root.settingsStickyEntry.group === modelData.group
// Breathing room above each heading, except the first.
Item {
visible: isGroup && index > 0 && !yieldsToBar
width: parent.width
height: visible ? Style.space(18) : 0
}
// A group heading is a row of its own rather than a label on
// the first setting under it: the pinned indicator reads
// delegate geometry to tell which section the view is inside,
// and a heading carried by another row has no position of its
// own to be found at.
Item {
visible: isGroup && !yieldsToBar
width: parent.width
height: visible ? Style.space(22) : 0
PanelSectionHeader {
textFormat: Text.PlainText
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: String(modelData.label || "").toUpperCase()
foreground: root.fg
fontFamily: root.fontFamily
}
}
// A setting whose dependency is missing is shown but inert, with
// the reason stated rather than the control silently doing nothing.
readonly property bool blocked: !isGroup && root.settingBlocked(modelData)
Item {
visible: !isGroup
width: parent.width
implicitHeight: visible
? Math.max(settingTextCol.implicitHeight, settingControlRow.implicitHeight, Style.space(32))
: 0
// Keyboard cursor: a bar in the gutter, so the row it marks is
// unmistakable without recolouring the whole row.
Rectangle {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Style.space(3)
height: parent.height - Style.space(6)
radius: width / 2
color: Color.accent
visible: cursored
}
Column {
id: settingTextCol
anchors.left: parent.left
anchors.leftMargin: cursored ? Style.space(10) : 0
anchors.right: settingControlRow.left
anchors.rightMargin: Style.space(12)
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
Text {
textFormat: Text.PlainText
width: parent.width
text: modelData.label
color: blocked ? root.dim : root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
width: parent.width
// `|| ""` because this binding also runs for the heading
// rows, which carry no description: an invisible item's
// bindings are evaluated all the same, and undefined
// reaches a QString property as a warning per frame.
text: blocked
? "Needs fingerprint setup -- see Dependencies below."
: (modelData.description || "")
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
Row {
id: settingControlRow
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(8)
ToggleSwitch {
anchors.verticalCenter: parent.verticalCenter
visible: modelData.type === "bool"
checked: modelData.type === "bool" && root.settingValue(modelData)
interactive: !blocked
foreground: root.fg
accent: Color.accent
onToggled: {
if (blocked) return
// A PIN cannot simply be switched on: it has to be chosen,
// and encrypting it needs the master password.
if (modelData.action === "pin") {
if (checked) root.disablePinUnlock()
else root.beginPinSetup()
return
}
if (modelData.action === "fingerprint") {
if (checked) root.forgetFingerprintUnlock()
else root.beginFingerprintSetup()
return
}
root.writeSetting(modelData.key, !checked, "bool")
}
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
visible: modelData.type === "int" && !!modelData.unit
text: modelData.unit || ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
NumberField {
anchors.verticalCenter: parent.verticalCenter
visible: modelData.type === "int"
value: modelData.type === "int" ? root.settingValue(modelData) : 0
from: modelData.min || 0
to: modelData.max || 100
stepSize: modelData.step || 1
foreground: root.fg
accent: Color.accent
fontFamily: root.fontFamily
onModified: function(v) { root.writeSetting(modelData.key, v, "int") }
}
}
}
Text {
textFormat: Text.PlainText
visible: modelData.type === "int" && root.settingValue(modelData) === 0 && !!modelData.zeroLabel
text: (modelData.zeroLabel || "") + " -- this is disabled."
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
PanelSeparator { width: parent.width }
// The SSH agent has more to say than its four toggles: what the
// helper is doing, and whether the user's terminals will reach
// it. That block used to sit after all four groups, which was
// survivable while nothing folded -- now it would leave a
// collapsed SSH Agent section with its status still on screen,
// attached to nothing. It loads at the end of the group it
// belongs to, so folding the section folds the whole section.
//
// A Loader rather than a visible binding: this delegate is
// instantiated for every row, and only one of them wants it.
Loader {
width: parent.width
active: !isGroup && modelData.group === "sshAgent"
&& modelData.lastInGroup === true
visible: active
sourceComponent: SshAgentSettings { panel: root }
}
}
}
Item { width: parent.width; height: Style.space(18) }
PanelSectionHeader {
textFormat: Text.PlainText
text: "MAINTENANCE"
foreground: root.fg
fontFamily: root.fontFamily
}
Row {
width: parent.width
spacing: Style.space(8)
Button {
text: "Dependencies"
iconText: "󰏗"
tooltipText: "Check the tools this plugin needs"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: {
root.setupDismissed = false
root.checkDependencies()
root.currentScreen = "setup"
}
}
Button {
visible: root.fingerprintStored
text: "Forget Fingerprint"
iconText: "󰈷"
tooltipText: "Remove the stored master password from the OS keyring"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.forgetFingerprintUnlock()
}
}
// Everything below this line destroys something. It was drawn in a
// row visually identical to the one above it, so "Dependencies" and
// "Remove Plugin Data" looked equally safe to press.
Item { width: parent.width; height: Style.space(18) }
PanelSeparator { width: parent.width }
PanelSectionHeader {
textFormat: Text.PlainText
text: "DANGER ZONE"
foreground: Color.urgent
fontFamily: root.fontFamily
}
// Its own row: this sits beside two buttons already, and a third
// one plus the two the confirmation adds overflow the panel width
// and elide their labels -- "Remove Plugin Data" reading as
// "Remove Plugin" is a considerably more alarming button.
Row {
width: parent.width
spacing: Style.space(8)
Button {
visible: !root.pluginDataConfirmPending
text: "Remove Plugin Data"
iconText: "󰩹"
tooltipText: "Clear the keyring entries, learned suggestions and exported public keys this plugin stored"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
enabled: !root.pluginDataBusy
onClicked: root.beginPluginDataRemoval()
}
Button {
visible: root.pluginDataConfirmPending
text: "Remove Everything"
iconText: "󰩹"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
enabled: !root.pluginDataBusy
onClicked: root.beginPluginDataRemoval()
}
Button {
visible: root.pluginDataConfirmPending
text: "Cancel"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.cancelPluginDataRemoval()
}
}
// Run this before removing the plugin: once the folder is gone
// there is no code left to do it, and `omarchy plugin remove` has
// no uninstall hook to call.
Text {
textFormat: Text.PlainText
width: parent.width
visible: root.pluginDataConfirmPending
text: "This clears the stored master password, learned suggestions and exported public keys. "
+ "Settings and your vault are untouched. It cannot be undone."
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Text {
textFormat: Text.PlainText
width: parent.width
visible: root.pluginDataFlash !== ""
text: root.pluginDataFlash
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Text {
textFormat: Text.PlainText
width: parent.width
text: "Saved to the plugin's entry in ~/.config/omarchy/shell.json via `omarchy bar set`."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
}
}
// An SSH request waiting on an unlock. Shown above whatever unlock
// control the vault is configured for, so the reason for the prompt
// is visible without the unlock itself authorising anything.
Column {
// Stays up through the load as well as the unlock: the request is
// held across the vault read, so dropping the block the moment the
// vault unlocks would leave the user watching nothing for seconds.
visible: !root.sshAgentApprovalPopup && root.sshUnlockRequest !== null
&& (root.status === "locked" || root.sshAgentLoadActive)
width: parent.width
spacing: Style.space(6)
PanelSeparator { width: parent.width }
Text {
textFormat: Text.PlainText
width: parent.width
text: "󰌆 An SSH key is needed"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
SshCaption {
text: !root.sshUnlockRequest
? ""
: (root.sshUnlockRequest.keyName !== ""
? root.sshUnlockRequest.keyName + " · requested by "
+ root.sshUnlockRequest.processName
// An identity listing names no key: the client is asking
// which keys exist, and until the vault is open there is no
// answer to give.
: root.sshUnlockRequest.processName
+ " is asking which SSH keys are available")
color: root.fg
}
SshCaption {
text: root.sshAgentLoadActive
? Model.sshAgentLoadingNote()
: "Unlocking loads your keys. You will still be asked before anything is signed."
}
Row {
width: parent.width
spacing: Style.space(8)
Button {
visible: !root.sshAgentLoadActive
text: "Not now (Esc)"
iconText: "󰅘"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.denySshRequest()
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.sshPromptRemainingSec + "s left"
color: root.sshPromptRemainingSec <= 5 ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
}
// SCREEN: SSH signing approval, in SshApprovalScreen.qml.
SshApprovalScreen {
panel: root
active: !root.sshAgentApprovalPopup && root.activeScreen === "sshApproval"
}
// -------------------------------------------------------------------
// SCREEN 1: LOGIN VIEW (When unauthenticated)
// -------------------------------------------------------------------
Column {
visible: root.status === "unauthenticated" && root.activeScreen !== "settings" && root.activeScreen !== "setup" && root.activeScreen !== "pin" && root.activeScreen !== "fingerprint"
width: parent.width
spacing: Style.space(12)
PanelSeparator { width: parent.width }
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(8)
Button {
text: "Email & Password"
iconText: "󰇮"
selected: root.loginMethod === "email"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: {
root.invalidateEmailLoginPrewarm()
root.resetEmailLoginSecondFactor()
root.loginMethod = "email"
}
}
Button {
text: "API Key"
iconText: "󰌋"
selected: root.loginMethod === "apikey"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: {
root.invalidateEmailLoginPrewarm()
root.resetEmailLoginSecondFactor()
root.loginMethod = "apikey"
}
}
}
Column {
visible: root.loginMethod !== "email" || root.loginCredentialsStage
width: parent.width
spacing: Style.space(5)
Text {
textFormat: Text.PlainText
text: "SERVER REGION"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(6)
Button {
text: "US"
selected: root.loginServerRegion === "us"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.selectLoginServerRegion("us")
}
Button {
text: "EU"
selected: root.loginServerRegion === "eu"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.selectLoginServerRegion("eu")
}
Button {
text: "Custom"
selected: root.loginServerRegion === "custom"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.selectLoginServerRegion("custom")
}
}
TextField {
id: serverUrlField
visible: root.loginServerRegion === "custom"
width: parent.width
placeholderText: "https://vault.example.com"
text: root.loginServerUrl
onTextChanged: root.loginServerUrl = text
onTextEdited: {
root.loginServerUrl = text
root.resetEmailLoginSecondFactor()
root.invalidateEmailLoginPrewarm()
}
}
}
// METHOD A: Email & Password
Column {
visible: root.loginMethod === "email"
width: parent.width
spacing: Style.space(10)
Column {
visible: root.loginCredentialsStage
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "EMAIL ADDRESS"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
id: emailField
width: parent.width
placeholderText: "you@example.com"
text: root.loginEmail
onTextChanged: root.loginEmail = text
onTextEdited: {
root.loginEmail = text
root.resetEmailLoginSecondFactor()
root.invalidateEmailLoginPrewarm()
}
onAccepted: loginPassField.forceActiveFocus()
}
}
Column {
visible: root.loginCredentialsStage
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
Row {
width: parent.width
spacing: Style.space(6)
TextField {
id: loginPassField
width: parent.width - eyeBtnLogin.width - Style.space(6)
placeholderText: "Master password..."
password: !eyeBtnLogin.revealed
text: root.loginPassword
onTextChanged: root.loginPassword = text
onTextEdited: {
root.loginPassword = text
if (root.show2faField) {
root.resetEmailLoginSecondFactor()
root.invalidateEmailLoginPrewarm()
}
}
onActiveFocusChanged: {
if (activeFocus) root.prepareEmailLogin()
}
onAccepted: root.show2faField ? code2faField.forceActiveFocus() : root.submitLogin()
}
Button {
id: eyeBtnLogin
property bool revealed: false
iconText: revealed ? "󰈉" : "󰈈"
tooltipText: revealed ? "Hide password" : "Show password"
fontFamily: root.fontFamily
onClicked: revealed = !revealed
}
}
}
// New-device verification. bw takes this code from a prompt and
// from nothing else, so answering it here is the difference
// between finishing the login in the panel and sending the user to
// a terminal to do it. See deviceVerificationLoginCommand().
Column {
visible: root.showDeviceCodeField
width: parent.width
spacing: Style.space(3)
Text {
textFormat: Text.PlainText
text: "NEW DEVICE VERIFICATION"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Text {
textFormat: Text.PlainText
width: parent.width
text: "Bitwarden has not seen this machine before and emailed a code to "
+ "your login address. This is asked once per device."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
TextField {
id: deviceCodeField
width: parent.width
placeholderText: "Code from your email..."
text: root.loginDeviceCode
onTextChanged: root.loginDeviceCode = text
onAccepted: root.submitDeviceVerification()
}
Button {
width: parent.width
text: root.isLoading ? "Verifying device..." : "Verify Device & Unlock"
iconText: root.isLoading ? "󰑐" : "󰌋"
iconSpinning: root.isLoading
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.isLoading
onClicked: root.submitDeviceVerification()
}
Row {
width: parent.width
spacing: Style.space(6)
Button {
text: "Back to credentials"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: {
root.errorMessage = ""
root.resetEmailLoginSecondFactor()
root.invalidateEmailLoginPrewarm()
Qt.callLater(function() { loginPassField.forceActiveFocus() })
}
}
// Still here, because bw in a real terminal can answer
// anything this path cannot.
Button {
text: "Use Terminal Instead"
iconText: "󰞷"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.launchTerminalLogin()
}
}
}
// bw asks this question only when an account has more than one
// method it can use, and only a terminal ever got to see it. The
// pick is sent on its own, before any code is collected, so a
// wrong one costs a round trip rather than a typed code.
Column {
visible: root.show2faMethodPicker
width: parent.width
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: "TWO-STEP METHOD"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Text {
textFormat: Text.PlainText
width: parent.width
text: "Which one do you use for this account? Bitwarden is asked for a code "
+ "only after you choose, and the choice is remembered for next time."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Repeater {
model: Model.twoFactorMethods()
Column {
width: parent.width
spacing: Style.space(2)
Button {
width: parent.width
text: modelData.label
iconText: "󰌋"
fontFamily: root.fontFamily
enabled: !root.isLoading
onClicked: root.chooseTwoFactorMethod(modelData.method)
}
Text {
textFormat: Text.PlainText
width: parent.width
text: modelData.hint
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
}
}
Button {
text: "Back to credentials"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: {
root.errorMessage = ""
root.resetEmailLoginSecondFactor()
root.invalidateEmailLoginPrewarm()
Qt.callLater(function() { loginPassField.forceActiveFocus() })
}
}
}
// Bitwarden tells us whether this account needs a second factor.
Column {
visible: root.show2faField
width: parent.width
spacing: Style.space(3)
Text {
textFormat: Text.PlainText
text: root.login2faMethodLabel
? "TWO-STEP CODE (" + root.login2faMethodLabel.toUpperCase() + ")"
: "TWO-STEP VERIFICATION CODE (2FA)"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
TextField {
id: code2faField
width: parent.width
placeholderText: "6-digit Authenticator / Email verification code..."
text: root.login2faCode
onTextChanged: {
root.login2faCode = text
root.invalidateEmailLoginPrewarm()
}
onAccepted: root.submitLogin()
}
Row {
width: parent.width
spacing: Style.space(6)
Button {
text: "Back to credentials"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: {
root.errorMessage = ""
root.resetEmailLoginSecondFactor()
root.invalidateEmailLoginPrewarm()
Qt.callLater(function() { loginPassField.forceActiveFocus() })
}
}
// The escape hatch from a remembered method. It is the only
// way back to the question once an account has answered it, so
// it stays available even when nothing has gone wrong yet.
Button {
text: "Change method"
iconText: "󰑐"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: {
root.invalidateEmailLoginPrewarm()
root.reopenTwoFactorMethodPicker()
}
}
}
}
Button {
// The picker stage submits by choosing, and the device stage has
// its own button, so this one belongs to the stages that share
// the ordinary login command.
visible: !root.show2faMethodPicker && !root.showDeviceCodeField
width: parent.width
text: root.emailLoginButtonText()
iconText: root.logoutCleanupFailed ? "󰑐" : ((root.logoutPending || root.isLoading) ? "󰑐" : "󰌋")
iconSpinning: !root.logoutCleanupFailed && (root.logoutPending || root.isLoading)
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: root.logoutCleanupFailed || (!root.logoutPending && !root.isLoading)
onClicked: root.logoutCleanupFailed ? root.retryLogoutCleanup() : root.submitLogin()
}
}
// METHOD B: API Key
Column {
visible: root.loginMethod === "apikey"
width: parent.width
spacing: Style.space(10)
Column {
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "CLIENT ID"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
id: apiClientIdField
width: parent.width
placeholderText: "user.xxxxxxxx-xxxx-xxxx..."
text: root.loginClientId
onTextChanged: root.loginClientId = text
}
}
Column {
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "CLIENT SECRET"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
id: apiClientSecretField
width: parent.width
placeholderText: "Client secret string..."
password: true
text: root.loginClientSecret
onTextChanged: root.loginClientSecret = text
}
}
Column {
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "MASTER PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
id: apiMasterField
width: parent.width
placeholderText: "Master password to unlock vault..."
password: true
text: root.loginPassword
onTextChanged: root.loginPassword = text
onAccepted: root.submitLogin()
}
}
Button {
width: parent.width
text: root.logoutCleanupFailed ? "Retry Logout Cleanup" : (root.logoutPending ? "Finishing logout..." : (root.isLoading ? "Logging in..." : "Log In with API Key"))
iconText: root.logoutCleanupFailed ? "󰑐" : ((root.logoutPending || root.isLoading) ? "󰑐" : "󰌋")
iconSpinning: !root.logoutCleanupFailed && (root.logoutPending || root.isLoading)
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: root.logoutCleanupFailed || (!root.logoutPending && !root.isLoading)
onClicked: root.logoutCleanupFailed ? root.retryLogoutCleanup() : root.submitLogin()
}
}
// Normally the quieter of the two ways in. When Bitwarden has asked
// to verify this device it is the only one, so it stops being an
// aside and says what it is for.
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: root.loginDeviceVerification
? "Device verification needs a terminal:"
: "Prefer interactive TTY login?"
color: root.loginDeviceVerification ? Color.accent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: root.loginDeviceVerification
anchors.verticalCenter: parent.verticalCenter
}
Button {
text: root.loginDeviceVerification ? "Finish in Terminal" : "Launch Terminal"
iconText: "󰞷"
selected: root.loginDeviceVerification
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.launchTerminalLogin()
}
}
}
// -------------------------------------------------------------------
// SCREEN 2: LOCKED VIEW (When authenticated, but vault locked)
// -------------------------------------------------------------------
Column {
visible: (root.status === "locked" || root.status === "checking")
&& root.currentScreen !== "settings" && root.currentScreen !== "setup" && root.currentScreen !== "pin" && root.currentScreen !== "fingerprint"
width: parent.width
spacing: Style.space(14)
PanelSeparator { width: parent.width }
Item { height: Style.space(8); width: 1 }
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: root.fingerprintScanning ? "󰈷" : "󰌋"
color: root.fingerprintScanning ? Color.accent : root.fg
opacity: 0.85
font.family: root.fontFamily
font.pixelSize: Style.space(38)
SequentialAnimation on opacity {
running: root.fingerprintScanning
loops: Animation.Infinite
NumberAnimation { to: 0.35; duration: 700; easing.type: Easing.InOutQuad }
NumberAnimation { to: 0.95; duration: 700; easing.type: Easing.InOutQuad }
onStopped: parent.opacity = 0.85
}
}
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: root.fingerprintReady ? "Unlock Vault" : "Enter Master Password"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
}
Text {
textFormat: Text.PlainText
visible: root.userEmail !== ""
anchors.horizontalCenter: parent.horizontalCenter
text: root.userEmail
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
// Fingerprint status / prompt
Text {
textFormat: Text.PlainText
visible: root.fingerprintMessage !== ""
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.fingerprintMessage
color: root.fingerprintScanning ? Color.accent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
// Offered when fingerprint unlock is on but nothing is stored yet.
Text {
textFormat: Text.PlainText
visible: root.fingerprintUnlock && root.fingerprintAvailable && !root.fingerprintStored
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: "󰈷 Unlock once with your master password to enable fingerprint unlock."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// PIN entry, offered above the password field when one is set.
Column {
visible: root.pinReady
width: parent.width
spacing: Style.space(8)
Text { textFormat: Text.PlainText; text: "PIN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
Row {
width: parent.width
spacing: Style.space(8)
TextField {
id: pinField
width: parent.width - pinUnlockBtn.width - Style.space(8)
placeholderText: "Enter your PIN..."
password: true
text: root.pinEntry
onTextChanged: root.pinEntry = text.replace(/[^0-9]/g, "")
onAccepted: root.submitPinUnlock()
enabled: !root.pinBusy && !root.isUnlocking
}
Button {
id: pinUnlockBtn
text: root.pinBusy ? "Checking..." : "Unlock"
iconText: root.pinBusy ? "󰑐" : "󰌿"
iconSpinning: root.pinBusy
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.pinBusy && !root.isUnlocking
onClicked: root.submitPinUnlock()
}
}
Text {
textFormat: Text.PlainText
visible: root.pinError !== ""
width: parent.width
text: root.pinError
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Text {
textFormat: Text.PlainText
text: "or use your master password below"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// A PIN was set but the vault rejected it -- surfaced even once
// pinReady has gone false, so the reason is not lost.
Text {
textFormat: Text.PlainText
visible: !root.pinReady && root.pinError !== ""
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.pinError
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Column {
width: parent.width
spacing: Style.space(10)
Button {
visible: root.fingerprintReady
width: parent.width
text: root.fingerprintScanning ? "Waiting for fingerprint..." : "Unlock with Fingerprint"
iconText: "󰈷"
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.isUnlocking && !root.fingerprintScanning
onClicked: root.startFingerprintUnlock()
}
Row {
width: parent.width
spacing: Style.space(8)
TextField {
id: passField
width: parent.width - eyeBtnUnlock.width - Style.space(8)
placeholderText: "Master password..."
password: !eyeBtnUnlock.revealed
text: root.masterPassword
onTextChanged: root.masterPassword = text
onActiveFocusChanged: {
if (activeFocus) root.prepareUnlock()
}
onAccepted: root.unlockVault()
enabled: !root.isUnlocking
}
Button {
id: eyeBtnUnlock
property bool revealed: false
iconText: revealed ? "󰈉" : "󰈈"
tooltipText: revealed ? "Hide password" : "Show password"
fontFamily: root.fontFamily
onClicked: revealed = !revealed
}
}
Button {
width: parent.width
text: root.isUnlocking ? "Unlocking..." : "Unlock Vault"
iconText: root.isUnlocking ? "󰑐" : "󰌋"
iconSpinning: root.isUnlocking
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.isUnlocking
onClicked: root.unlockVault()
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(8)
Button {
text: "Switch / Log Out"
iconText: "󰍃"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.logoutAccount()
}
Button {
visible: root.fingerprintStored
text: "Forget Fingerprint"
iconText: "󰈷"
tooltipText: "Remove the stored master password from the OS keyring"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.forgetFingerprintUnlock()
}
}
}
// -------------------------------------------------------------------
// SCREEN 3: UNLOCKED - ITEM LIST VIEW
// -------------------------------------------------------------------
Column {
visible: root.status === "unlocked" && root.activeScreen === "main"
width: parent.width
spacing: Style.space(8)
// Search Field
Row {
width: parent.width
spacing: Style.space(6)
TextField {
id: searchField
width: parent.width - (root.searchQuery ? clearSearchBtn.width + Style.space(6) : 0)
placeholderText: "Search items, usernames, URLs, public keys, fingerprints..."
text: root.searchQuery
onTextChanged: {
root.searchQuery = text
root.selectedIndex = 0
root.closeFilterGroup()
searchDebounceTimer.restart()
}
// Alt+letter runs the same shortcuts without leaving the box.
Keys.onPressed: function(event) {
if (!(event.modifiers & Qt.AltModifier)) return
if (!event.text) return
if (root.runAltShortcut(String(event.text).toLowerCase())) {
event.accepted = true
}
}
Keys.onDownPressed: {
keyCatcher.forceActiveFocus()
root.moveCursor(1)
}
Keys.onReturnPressed: {
var itm = root.getSelectedItem()
if (itm) root.handleSmartEnter(itm)
}
// Only while the search box is the screen. A hidden item keeps
// active focus in Qt, so without this guard the search field
// still owned Escape from behind the item form and closed the
// whole panel instead of cancelling the edit.
Keys.onEscapePressed: function(event) {
if (root.currentScreen !== "main") {
event.accepted = false // let it reach the panel's dispatch
return
}
if (text) text = ""
else root.handleEscape()
}
}
PanelActionButton {
id: clearSearchBtn
visible: root.searchQuery !== ""
iconText: "󰅖"
tooltipText: "Clear search"
fontFamily: root.fontFamily
onClicked: searchField.text = ""
}
}
// Contextual Suggestion Banner
BorderSurface {
visible: Boolean(root.suggestedItems.length > 0 && !root.suggestionsDismissed && root.searchQuery.trim() === "" && root.detectedContext && root.detectedContext.displayName)
width: parent.width
implicitHeight: Style.space(28)
radius: Style.cornerRadius
color: Style.selectedFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", Color.accent, Color.accent)
// A RowLayout, so the label can be told to take whatever the glyph
// and the dismiss button leave rather than a hand-measured slice of
// the banner. The name in it is a window title, so its length is
// not ours to predict.
RowLayout {
anchors.fill: parent
anchors.leftMargin: Style.space(8)
anchors.rightMargin: Style.space(6)
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
Layout.alignment: Qt.AlignVCenter
text: "󰌠"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
textFormat: Text.PlainText
Layout.alignment: Qt.AlignVCenter
Layout.fillWidth: true
text: "Suggested for " + (root.detectedContext ? root.detectedContext.displayName : "active window")
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
elide: Text.ElideRight
}
PanelActionButton {
Layout.alignment: Qt.AlignVCenter
iconText: "󰅖"
tooltipText: "Dismiss suggestion"
fontFamily: root.fontFamily
size: Style.space(18)
fontSize: Style.font.caption
onClicked: {
root.suggestionsDismissed = true
root.rebuildFilter()
}
}
}
}
PanelSeparator { width: parent.width }
// Item List View (Fast Virtualized ListView with Delegate Recycling)
Item {
width: parent.width
height: Style.space(320)
ListView {
id: itemsListView
anchors.fill: parent
clip: true
model: root.filteredItems
spacing: Style.space(4)
boundsBehavior: Flickable.StopAtBounds
reuseItems: true
currentIndex: root.selectedIndex
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: itemsListView }
delegate: BorderSurface {
id: itemRow
required property var modelData
required property int index
readonly property var itemData: modelData
readonly property bool isSelected: root.cursorActive && root.selectedIndex === index
readonly property bool isHovered: rowMouseArea.containsMouse
width: ListView.view.width - root.scrollGutter
implicitHeight: Style.space(46)
radius: Style.cornerRadius
color: isSelected
? Style.selectedFillFor(root.fg, Color.accent)
: (isHovered ? Style.hoverFillFor(root.fg, Color.accent) : "transparent")
borderSpec: isSelected
? Border.controlSpec("selected", root.fg, Color.accent)
: Border.none()
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(8)
spacing: Style.space(10)
// Type Icon, or a spinner while the vault is being told about
// this row. The glyph is the row's identity, so the saving
// state borrows it rather than adding a second marker and
// reflowing everything beside it.
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: itemData.pending ? "󰑐" : Model.itemTypeGlyph(itemData.typeCode)
color: itemData.pending
? root.dim
: (itemData.favorite ? Color.accent : root.fg)
font.family: root.fontFamily
font.pixelSize: Style.font.title
width: Style.space(20)
// The glyph is narrower than the column it sits in, so
// without centring it the spin happens about the middle of
// the box and the icon orbits that point instead of
// turning on its own axis. Same shape the kit's own
// spinning button icon uses.
horizontalAlignment: Text.AlignHCenter
transformOrigin: Item.Center
RotationAnimation on rotation {
running: Boolean(itemData.pending)
loops: Animation.Infinite
from: 0
to: 360
duration: 900
}
}
// Labels (Title + Subtitle + Org Tag)
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(20) - actionButtonsRow.implicitWidth - Style.space(28)
spacing: Style.space(1)
Row {
spacing: Style.space(4)
width: parent.width
Text {
textFormat: Text.PlainText
text: itemData.name
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.width
- (itemData.favorite ? Style.space(16) : 0)
- (itemData.hasAttachments ? Style.space(18) : 0))
}
Text {
textFormat: Text.PlainText
visible: itemData.favorite
text: "★"
color: Color.accent
font.pixelSize: Style.font.bodySmall
anchors.verticalCenter: parent.verticalCenter
}
// A paperclip is the whole badge: the file names live in
// the detail view, and the row only has to say they exist.
Text {
textFormat: Text.PlainText
visible: Boolean(itemData.hasAttachments)
text: "󰏢"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
anchors.verticalCenter: parent.verticalCenter
}
}
Row {
spacing: Style.space(4)
width: parent.width
Text {
textFormat: Text.PlainText
visible: Boolean(itemData.isSuggested)
text: root.learnedIds[itemData.id] ? "󰐾 Suggested" : "󰌠 Suggested"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Text {
textFormat: Text.PlainText
visible: Boolean(itemData.organizationId)
text: "󰓹 Org"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Text {
textFormat: Text.PlainText
id: rowSubtitle
text: itemData.subtitle || Model.itemTypeLabel(itemData.typeCode)
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
// Take only what is needed, so the folder tag that follows
// keeps its place instead of being pushed off the row.
width: Math.min(implicitWidth,
parent.width
- (itemData.organizationId ? Style.space(40) : 0)
- (itemData.isSuggested ? Style.space(75) : 0)
- (rowFolderTag.visible ? Style.space(90) : 0))
}
Text {
textFormat: Text.PlainText
id: rowFolderTag
// Only worth showing when it is not already implied by the filter.
visible: Boolean(itemData.folderId) && root.selectedFolder === "all"
text: "· 󰉋 " + Model.folderName(root.folders, itemData.folderId)
color: Qt.darker(root.dim, 1.1)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
elide: Text.ElideRight
width: Math.min(implicitWidth, Style.space(90))
}
}
}
// Quick Action Buttons
Row {
id: actionButtonsRow
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(4)
visible: isSelected || isHovered
PanelActionButton {
visible: itemData.typeCode !== 5 && itemData.hasPassword
iconText: "󰌆"
tooltipText: "Copy password (Enter / y)"
fontFamily: root.fontFamily
onClicked: root.handleSmartEnter(itemData)
}
PanelActionButton {
visible: itemData.typeCode !== 5 && itemData.username !== ""
iconText: ""
tooltipText: "Copy username (u)"
fontFamily: root.fontFamily
onClicked: root.copyUsername(itemData)
}
PanelActionButton {
visible: itemData.typeCode !== 5 && itemData.hasTotp
iconText: "󰥔"
tooltipText: "Copy TOTP code (m)"
fontFamily: root.fontFamily
onClicked: root.copyTotpCode(itemData)
}
PanelActionButton {
iconText: "󰏫"
tooltipText: itemData.typeCode === 5 ? "View public key" : "View / Edit item (e)"
fontFamily: root.fontFamily
onClicked: root.openDetail(itemData)
}
PanelActionButton {
visible: itemData.typeCode !== 5 && itemData.uris && itemData.uris.length > 0
iconText: "󰖟"
tooltipText: "Open URL (w)"
fontFamily: root.fontFamily
onClicked: root.openUrl(itemData.uris[0])
}
}
}
MouseArea {
id: rowMouseArea
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.cursorActive = true
root.openFilterGroup = ""
root.selectedIndex = index
root.openDetail(itemData)
}
}
}
}
// Empty state overlay
Item {
visible: root.filteredItems.length === 0
anchors.fill: parent
Column {
anchors.centerIn: parent
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: root.isLoading && root.items.length === 0 ? "󰑐" : (root.items.length === 0 ? "󰞀" : "󰍡")
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.space(36)
RotationAnimation on rotation {
running: root.isLoading && root.items.length === 0
from: 0
to: 360
duration: 900
loops: Animation.Infinite
}
}
Text {
textFormat: Text.PlainText
anchors.horizontalCenter: parent.horizontalCenter
text: root.isLoading && root.items.length === 0
? "Loading items..."
: root.emptyListMessage()
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
}
}
}
// -----------------------------------------------------------------
// Bottom filter bar: Folders / Vaults / Types
// -----------------------------------------------------------------
// Three horizontally scrolling strips were easy to miss and awkward
// to reach. One collapsed row instead, each opening a vertical list
// in place; the item list gives back exactly the height the open
// list takes, so the panel does not jump.
PanelSeparator { width: parent.width }
// The open group's options: a pinned header naming the group, then up
// to five rows with the rest scrolling underneath it.
Column {
id: filterDrawer
width: parent.width
height: root.filterDrawerHeight
visible: height > 0
clip: true
spacing: 0
Behavior on height { NumberAnimation { duration: 130; easing.type: Easing.OutQuad } }
// Pinned header -- stays put while the options scroll.
Row {
width: parent.width
height: Style.space(30)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.openFilterGroup === "folders" ? "󰉋"
: root.openFilterGroup === "organizations" ? "󰦑"
: "󰀻"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.openFilterGroup === "folders" ? "FOLDERS"
: root.openFilterGroup === "organizations" ? "ORGANIZATIONS"
: "TYPES"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Item { width: parent.width - Style.space(190); height: 1 }
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
visible: root.currentFilterOptions.length > root.currentFilterVisibleRows
text: root.currentFilterOptions.length + " total"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Flickable {
id: filterOptionsList
width: parent.width
height: Math.min(root.currentFilterVisibleRows, root.currentFilterOptions.length) * root.filterRowHeight
contentWidth: width
contentHeight: filterOptionsCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: filterOptionsList }
// Keep the keyboard cursor in view when it runs past the fold.
function revealCursor() {
var y = root.filterOptionIndex * root.filterRowHeight
if (y < contentY) contentY = y
else if (y + root.filterRowHeight > contentY + height) {
contentY = y + root.filterRowHeight - height
}
}
Connections {
target: root
function onFilterOptionIndexChanged() { filterOptionsList.revealCursor() }
}
Column {
id: filterOptionsCol
width: filterOptionsList.width - root.scrollGutter
spacing: 0
Repeater {
model: root.currentFilterOptions
delegate: BorderSurface {
required property var modelData
required property int index
width: filterOptionsCol.width
implicitHeight: root.filterRowHeight
radius: Style.cornerRadius
readonly property bool cursored: index === root.filterOptionIndex
color: modelData.active ? Style.selectedFillFor(root.fg, Color.accent)
: (cursored || optionMouse.containsMouse) ? Style.hoverFillFor(root.fg, Color.accent)
: "transparent"
borderSpec: Border.surfaceSpec("menu", "border",
(modelData.active || cursored) ? Color.accent : "transparent",
(modelData.active || cursored) ? 1 : 0)
MouseArea {
id: optionMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: root.filterOptionIndex = index
onClicked: root.applyFilterOption(root.openFilterGroup, modelData.id)
}
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(10)
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: modelData.icon
color: modelData.active ? Color.accent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.body
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(50)
text: modelData.label
color: modelData.active ? Color.accent : root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: modelData.active
elide: Text.ElideRight
}
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
visible: modelData.active
text: "󰄬"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
}
}
}
}
}
// The three collapsed buttons. Identical shape, so none reads as a
// different kind of control from the others -- which is why they are
// one component declared three times rather than three buttons.
//
// A Flow rather than a Row. Two of the three carry a vault name, so
// their width is whatever the user typed, and a Row can neither
// shrink a child nor start a second line -- it lays the overflow out
// past the panel edge, off both sides at once because the group is
// centred. `width` is the group's own combined width while the three
// share a line, which is what keeps it centred, and the panel's when
// they cannot. It reads the buttons' implicitWidth, never their
// width, so the layout's width never depends on its own result.
//
// This is what pays for the labels naming their filters: the three
// fit one line in the ordinary case and take a second when they do
// not, instead of the names having to be dropped to guarantee one.
Flow {
anchors.horizontalCenter: parent.horizontalCenter
spacing: Style.space(6)
readonly property real naturalWidth: folderFilterButton.implicitWidth
+ organizationFilterButton.implicitWidth
+ typeFilterButton.implicitWidth
+ spacing * 2
width: Math.min(parent.width, naturalWidth)
VaultFilterButton {
id: folderFilterButton
group: "folders"
glyph: "󰉋"
name: "Folders"
value: root.folderFilterLabel()
shortcut: "f"
}
VaultFilterButton {
id: organizationFilterButton
group: "organizations"
glyph: "󰦑"
name: "Organizations"
value: root.organizationFilterLabel()
shortcut: "o"
}
VaultFilterButton {
id: typeFilterButton
group: "types"
glyph: "󰀻"
name: "Types"
value: root.typeFilterLabel()
shortcut: "t"
}
}
}
// -------------------------------------------------------------------
// SCREEN 4: UNLOCKED - ITEM DETAIL VIEW
// -------------------------------------------------------------------
Column {
visible: root.status === "unlocked" && root.activeScreen === "detail"
width: parent.width
spacing: Style.space(12)
// Back Navigation & Action Header
//
// A Flow, not a Row, because how many buttons are here is decided at
// runtime: the suggestion button appears only on a recognised window,
// and it is the widest of the four. A Row cannot shrink a child or
// start a second line, so the fourth button was laid out past the
// panel's right edge and Delete simply left the panel -- worse still
// only once the suggestion was pinned, because "Suggested here" is a
// character wider than "Suggest here" and that character was the one
// that overflowed. The panel is also narrower than its 450 ask on a
// small screen (see fittedContentWidth), so no arrangement of fixed
// labels is safe; wrapping is. Everything still fits on one line at
// the default size, so this only shows itself when it has to.
Flow {
width: parent.width
spacing: Style.space(8)
Button {
// "Back to list" spelled out cost more width than the row could
// spare, and the Sends screen already says just "Back (Esc)".
text: "Back (Esc)"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.currentScreen = "main"
}
Button {
visible: Boolean(root.detectedContext && root.detectedContext.displayName && root.detailItem && root.detailItem.typeCode !== 5)
readonly property bool pinned: Boolean(root.detailItem
&& Model.isAssociated(root.associations, root.detectedContext, root.detailItem.id))
text: pinned ? "Suggested here" : "Suggest here"
iconText: pinned ? "󰐾" : "󰐽"
selected: pinned
accent: Color.accent
// The window title is no more trustworthy than a vault value,
// and the kit renders tooltips with an auto-detecting Text.
tooltipText: Model.plainLabel((pinned ? "Stop suggesting this for " : "Always suggest this for ")
+ (root.detectedContext ? root.detectedContext.displayName : ""))
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.toggleAssociation(root.detailItem)
}
Button {
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5)
text: "Edit"
iconText: "󰏫"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: if (root.detailItem) root.startEditItem(root.detailItem)
}
Button {
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5)
text: "Delete"
iconText: "󰆴"
accent: Color.urgent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.showDeleteConfirm = true
}
}
// Delete Confirmation Banner
BorderSurface {
visible: root.showDeleteConfirm
width: parent.width
implicitHeight: Style.space(64)
color: Util.alpha(Color.urgent, 0.15)
radius: Style.cornerRadius
borderSpec: Border.surfaceSpec("menu", "border", Color.urgent, 1)
Row {
anchors.centerIn: parent
spacing: Style.space(12)
Text {
textFormat: Text.PlainText
text: "Permanently delete this item?"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
Button {
text: "Confirm Delete"
iconText: "󰆴"
selected: true
accent: Color.urgent
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.deleteCurrentItem()
}
Button {
text: "Cancel"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.showDeleteConfirm = false
}
}
}
PanelSeparator { width: parent.width }
Flickable {
id: detailFlickable
width: parent.width
height: Math.min(Style.space(380), detailContentColumn.implicitHeight)
contentWidth: width
contentHeight: detailContentColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: detailFlickable }
Column {
id: detailContentColumn
width: detailFlickable.width - root.scrollGutter
spacing: Style.space(12)
// Item Header
Row {
width: parent.width
spacing: Style.space(10)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.detailItem ? Model.itemTypeGlyph(root.detailItem.typeCode) : "󰌋"
color: (root.detailItem && root.detailItem.favorite) ? Color.accent : root.fg
font.family: root.fontFamily
font.pixelSize: Style.space(26)
}
Column {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - Style.space(40)
spacing: Style.space(2)
Row {
spacing: Style.space(6)
width: parent.width
Text {
textFormat: Text.PlainText
text: root.detailItem ? root.detailItem.name : "Loading..."
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
elide: Text.ElideRight
width: Math.min(implicitWidth, parent.width - Style.space(20))
}
Text {
textFormat: Text.PlainText
visible: Boolean(root.detailItem && root.detailItem.favorite)
text: "★"
color: Color.accent
font.pixelSize: Style.font.body
}
}
Row {
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: root.detailItem ? Model.itemTypeLabel(root.detailItem.typeCode) : ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
textFormat: Text.PlainText
visible: Boolean(root.detailItem && root.detailItem.organizationId)
text: "• Shared Organization"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
textFormat: Text.PlainText
visible: Boolean(root.detailItem && root.detailItem.folderId)
text: root.detailItem
? "• 󰉋 " + Model.folderName(root.folders, root.detailItem.folderId)
: ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
}
}
// FIELD: Public SSH key (type 5 is deliberately read-only)
Column {
visible: Boolean(root.detailItem && root.detailItem.typeCode === 5)
width: parent.width
spacing: Style.space(4)
PanelSectionHeader { text: "PUBLIC KEY" }
BorderSurface {
width: parent.width
implicitHeight: Math.max(Style.space(54), sshPublicKeyText.implicitHeight + Style.space(20))
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Text {
textFormat: Text.PlainText
id: sshPublicKeyText
anchors.fill: parent
anchors.margins: Style.space(10)
text: root.detailItem ? (root.detailItem.publicKey || "No public key") : ""
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WrapAnywhere
}
}
Text {
textFormat: Text.PlainText
visible: Boolean(root.detailItem && root.detailItem.fingerprint)
text: "Fingerprint: " + (root.detailItem ? root.detailItem.fingerprint : "")
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WrapAnywhere
}
}
// FIELD: Username
DetailField {
visible: root.detailIsLoginLike && Boolean(root.detailItem) && root.detailItem.username !== ""
label: "Username / Email"
copyLabel: "Username"
shortcutHint: "u"
copyIcon: ""
value: root.detailItem ? root.detailItem.username : ""
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailItem ? root.detailItem.username : "", "Username")
}
// FIELD: Password
Column {
visible: root.detailIsLoginLike && Boolean(root.detailItem) && (root.detailPassword !== "" || root.detailItem.hasPassword)
width: parent.width
spacing: Style.space(4)
PanelSectionHeader { text: "PASSWORD" }
BorderSurface {
width: parent.width
implicitHeight: Style.space(34)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(6)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.isFieldRevealed("password")
? root.detailPassword : Model.maskString(root.detailPassword || "password")
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
elide: Text.ElideRight
width: parent.width - passActions.width - Style.space(10)
}
Row {
id: passActions
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(4)
PanelActionButton {
iconText: root.isFieldRevealed("password") ? "󰈉" : "󰈈"
tooltipText: root.isFieldRevealed("password") ? "Hide password (v)" : "Reveal password (v)"
fontFamily: root.fontFamily
onClicked: root.toggleFieldReveal("password")
}
PanelActionButton {
iconText: "󰌆"
tooltipText: "Copy password (y / Enter)"
fontFamily: root.fontFamily
onClicked: root.copyToClipboard(root.detailPassword, "Password")
}
}
}
}
}
// FIELD: TOTP (2FA Code)
Column {
visible: root.detailIsLoginLike && Boolean(root.detailItem) && root.detailItem.hasTotp
width: parent.width
spacing: Style.space(4)
RowLayout {
width: parent.width
PanelSectionHeader { text: "VERIFICATION CODE (TOTP)" }
Item { Layout.fillWidth: true }
Text {
textFormat: Text.PlainText
text: root.totpSecRemaining + "s"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
Layout.alignment: Qt.AlignVCenter
}
}
BorderSurface {
width: parent.width
implicitHeight: Style.space(44)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Rectangle {
anchors.left: parent.left
anchors.bottom: parent.bottom
height: Style.space(3)
radius: Style.cornerRadius
width: parent.width * (root.totpSecRemaining / 30.0)
color: Color.accent
}
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(12)
anchors.rightMargin: Style.space(6)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: root.liveTotp ? (root.liveTotp.length === 6 ? root.liveTotp.slice(0, 3) + " " + root.liveTotp.slice(3) : root.liveTotp) : "Loading..."
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
font.letterSpacing: 2.0
width: parent.width - copyTotpBtn.width - Style.space(10)
}
PanelActionButton {
id: copyTotpBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "󰥔"
tooltipText: "Copy TOTP code (m)"
fontFamily: root.fontFamily
enabled: root.liveTotp !== ""
onClicked: root.copyToClipboard(root.liveTotp, "TOTP code")
}
}
}
}
// FIELD: Website / URIs
Column {
visible: root.detailIsLoginLike && Boolean(root.detailItem) && root.detailItem.uris && root.detailItem.uris.length > 0
width: parent.width
spacing: Style.space(4)
PanelSectionHeader { text: "WEBSITE" }
Repeater {
model: root.detailItem ? root.detailItem.uris : []
delegate: BorderSurface {
width: detailContentColumn.width
implicitHeight: Style.space(34)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(6)
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: modelData
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
width: parent.width - openUriBtn.width - Style.space(10)
}
PanelActionButton {
id: openUriBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "󰖟"
tooltipText: "Open in browser (w)"
fontFamily: root.fontFamily
onClicked: root.openUrl(modelData)
}
}
}
}
}
// FIELD: Attachments
//
// The metadata came down with the item, so the list is here the
// moment the detail view opens; only the bytes cost a CLI call,
// and only for the file the user actually asks for.
//
// Above NOTES on purpose. Notes is the one section with no height
// of its own -- it grows with the text -- and this Flickable is
// capped, so anything after it starts below the fold on exactly
// the items whose note is long. A secure note with a file
// attached is that case, and the files were the thing being
// pushed out of sight.
Column {
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5 && root.detailItem.hasAttachments)
width: parent.width
spacing: Style.space(4)
RowLayout {
width: parent.width
spacing: Style.space(6)
PanelSectionHeader { text: "ATTACHMENTS" }
Item { Layout.fillWidth: true }
PanelActionButton {
visible: Boolean(root.detailItem && root.detailItem.attachments
&& root.detailItem.attachments.length > 1)
iconText: "󰇚"
tooltipText: "Save all attachments (a)"
size: Style.space(20)
fontFamily: root.fontFamily
onClicked: root.saveAllAttachments()
}
}
Repeater {
model: root.detailItem ? root.detailItem.attachments : []
delegate: BorderSurface {
readonly property string savedPath: root.attachmentSavedPath(modelData.id)
readonly property bool busy: root.attachmentBusyId === modelData.id
readonly property bool queued: root.isAttachmentQueued(modelData.id)
width: detailContentColumn.width
implicitHeight: Style.space(34)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Row {
anchors.fill: parent
anchors.leftMargin: Style.space(10)
anchors.rightMargin: Style.space(6)
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
id: attachmentGlyph
anchors.verticalCenter: parent.verticalCenter
text: "󰈔"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
// The file name is vault text, so it is drawn as text.
Text {
textFormat: Text.PlainText
anchors.verticalCenter: parent.verticalCenter
text: modelData.fileName
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
width: Math.max(0, parent.width - attachmentGlyph.width
- attachmentStatus.width - attachmentActions.width - Style.space(34))
}
Text {
textFormat: Text.PlainText
id: attachmentStatus
anchors.verticalCenter: parent.verticalCenter
text: busy ? "Saving..." : queued ? "Queued" : modelData.sizeName
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Row {
id: attachmentActions
anchors.verticalCenter: parent.verticalCenter
spacing: Style.space(2)
PanelActionButton {
visible: savedPath === ""
enabled: !busy && !queued
iconText: "󰇚"
tooltipText: "Save to your download folder"
fontFamily: root.fontFamily
onClicked: root.queueAttachment(modelData)
}
PanelActionButton {
visible: savedPath !== ""
iconText: "󰏌"
tooltipText: "Open the saved file"
fontFamily: root.fontFamily
onClicked: root.openSavedAttachment(modelData.id)
}
PanelActionButton {
visible: savedPath !== ""
iconText: "󰝰"
// The path is ours -- a download directory plus a
// sanitised name -- but it is still drawn as text.
tooltipText: Model.plainLabel("Show in " + Model.parentDirectory(savedPath))
fontFamily: root.fontFamily
onClicked: root.revealSavedAttachment(modelData.id)
}
}
}
}
}
}
// FIELD: Notes
Column {
visible: Boolean(root.detailItem && root.detailItem.typeCode !== 5 && root.detailItem.notes !== "")
width: parent.width
spacing: Style.space(4)
RowLayout {
width: parent.width
PanelSectionHeader { text: "NOTES" }
Item { Layout.fillWidth: true }
PanelActionButton {
iconText: "󰈙"
tooltipText: "Copy notes"
size: Style.space(20)
fontFamily: root.fontFamily
onClicked: if (root.detailItem) root.copyToClipboard(root.detailItem.notes, "Notes")
}
}
BorderSurface {
width: parent.width
implicitHeight: notesText.implicitHeight + Style.space(16)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Text {
textFormat: Text.PlainText
id: notesText
anchors.fill: parent
anchors.margins: Style.space(10)
text: root.detailItem ? root.detailItem.notes : ""
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.Wrap
}
}
}
// -----------------------------------------------------------
// FIELDS: Card
// -----------------------------------------------------------
// Expiry is one field rather than two. It is written, read and
// typed as a unit, and a vault that shows "04" above "2030" in
// two labelled boxes is describing its storage rather than the
// card in your hand.
DetailField {
visible: root.detailIsCard
label: "Cardholder Name"
value: root.detailCard ? root.detailCard.cardholderName : ""
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.cardholderName : "", "Cardholder name")
}
DetailField {
visible: root.detailIsCard
label: "Brand"
value: root.detailCard ? root.detailCard.brand : ""
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.brand : "", "Brand")
}
DetailField {
visible: root.detailIsCard
label: "Card Number"
copyLabel: "Card number"
shortcutHint: "n / Enter"
revealHint: "v"
sensitive: true
revealed: root.isFieldRevealed("cardNumber")
value: root.detailCard ? root.detailCard.number : ""
foreground: root.fg
fontFamily: root.fontFamily
onRevealToggled: root.toggleFieldReveal("cardNumber")
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.number : "", "Card number")
}
DetailField {
visible: root.detailIsCard
label: "Expires"
value: root.detailCardExpiry
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailCardExpiry, "Expiry")
}
DetailField {
visible: root.detailIsCard
label: "Security Code"
copyLabel: "Security code"
shortcutHint: "k"
sensitive: true
revealed: root.isFieldRevealed("cardCode")
value: root.detailCard ? root.detailCard.code : ""
foreground: root.fg
fontFamily: root.fontFamily
onRevealToggled: root.toggleFieldReveal("cardCode")
onCopyRequested: root.copyToClipboard(root.detailCard ? root.detailCard.code : "", "Security code")
}
// -----------------------------------------------------------
// FIELDS: Identity
// -----------------------------------------------------------
// Every field an identity can carry is declared; DetailField
// hides the empty ones. Most identities fill in a handful, and
// the alternative -- deciding here which are worth drawing --
// is how the useful one for somebody ends up missing.
DetailField {
visible: root.detailIsIdentity
label: "Name"
value: root.detailIdentityName
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailIdentityName, "Name")
}
DetailField {
visible: root.detailIsIdentity
label: "Username"
shortcutHint: "u"
value: root.detailIdentity ? root.detailIdentity.username : ""
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.username : "", "Username")
}
DetailField {
visible: root.detailIsIdentity
label: "Company"
value: root.detailIdentity ? root.detailIdentity.company : ""
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.company : "", "Company")
}
DetailField {
visible: root.detailIsIdentity
label: "Email"
shortcutHint: "c"
value: root.detailIdentity ? root.detailIdentity.email : ""
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.email : "", "Email")
}
DetailField {
visible: root.detailIsIdentity
label: "Phone"
value: root.detailIdentity ? root.detailIdentity.phone : ""
foreground: root.fg
fontFamily: root.fontFamily
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.phone : "", "Phone")
}
// The three an identity item usually exists to hold. Masked for
// the same reason a password is: a shoulder is enough to lose
// them, and unlike a password they cannot be rotated.
DetailField {
visible: root.detailIsIdentity
label: "Social Security Number"
copyLabel: "SSN"
sensitive: true
revealed: root.isFieldRevealed("ssn")
value: root.detailIdentity ? root.detailIdentity.ssn : ""
foreground: root.fg
fontFamily: root.fontFamily
onRevealToggled: root.toggleFieldReveal("ssn")
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.ssn : "", "SSN")
}
DetailField {
visible: root.detailIsIdentity
label: "Passport Number"
copyLabel: "Passport number"
sensitive: true
revealed: root.isFieldRevealed("passport")
value: root.detailIdentity ? root.detailIdentity.passportNumber : ""
foreground: root.fg
fontFamily: root.fontFamily
onRevealToggled: root.toggleFieldReveal("passport")
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.passportNumber : "", "Passport number")
}
DetailField {
visible: root.detailIsIdentity
label: "Licence Number"
copyLabel: "Licence number"
sensitive: true
revealed: root.isFieldRevealed("licence")
value: root.detailIdentity ? root.detailIdentity.licenseNumber : ""
foreground: root.fg
fontFamily: root.fontFamily
onRevealToggled: root.toggleFieldReveal("licence")
onCopyRequested: root.copyToClipboard(root.detailIdentity ? root.detailIdentity.licenseNumber : "", "Licence number")
}
PanelSectionHeader {
visible: root.detailIsIdentity && root.detailIdentityAddress !== ""
text: "ADDRESS"
}
// One block, not seven rows. An address is copied as an address.
BorderSurface {
visible: root.detailIsIdentity && root.detailIdentityAddress !== ""
width: parent.width
implicitHeight: addressText.implicitHeight + Style.space(16)
radius: Style.cornerRadius
color: Style.hoverFillFor(root.fg, Color.accent)
borderSpec: Border.controlSpec("normal", root.fg, Color.accent)
Row {
anchors.fill: parent
anchors.margins: Style.space(8)
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
id: addressText
anchors.verticalCenter: parent.verticalCenter
text: root.detailIdentityAddress
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.body
wrapMode: Text.Wrap
width: parent.width - copyAddressBtn.width - Style.space(10)
}
PanelActionButton {
id: copyAddressBtn
anchors.verticalCenter: parent.verticalCenter
iconText: "󰈙"
tooltipText: "Copy address"
fontFamily: root.fontFamily
onClicked: root.copyToClipboard(root.detailIdentityAddress, "Address")
}
}
}
}
}
}
// -------------------------------------------------------------------
// SCREEN 5: ADD / EDIT ITEM FORM VIEW
// -------------------------------------------------------------------
Column {
visible: root.status === "unlocked" && root.activeScreen === "edit"
width: parent.width
spacing: Style.space(10)
RowLayout {
width: parent.width
spacing: Style.space(8)
Button {
text: "Cancel (Esc)"
iconText: "󰁍"
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.currentScreen = root.formIsEditing ? "detail" : "main"
}
Item { Layout.fillWidth: true }
Text {
textFormat: Text.PlainText
Layout.alignment: Qt.AlignVCenter
text: root.formIsEditing ? "Edit Item" : "New Vault Item"
color: root.fg
font.family: root.fontFamily
font.pixelSize: Style.font.title
font.bold: true
}
}
PanelSeparator { width: parent.width }
Flickable {
id: editFlickable
width: parent.width
height: Math.min(Style.space(420), editFormCol.implicitHeight)
contentWidth: width
contentHeight: editFormCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: editFlickable }
Column {
id: editFormCol
width: editFlickable.width - root.scrollGutter
spacing: Style.space(10)
// Item Type Selector (only for new items)
Row {
visible: !root.formIsEditing
spacing: Style.space(8)
Button {
text: "Login"
iconText: "󰌋"
selected: root.formTypeCode === 1
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.formTypeCode = 1
}
Button {
text: "Secure Note"
iconText: "󰈙"
selected: root.formTypeCode === 2
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.formTypeCode = 2
}
Button {
text: "Card"
iconText: "󰿯"
selected: root.formTypeCode === 3
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.formTypeCode = 3
}
Button {
text: "Identity"
iconText: ""
selected: root.formTypeCode === 4
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.formTypeCode = 4
}
}
// FIELD: Title / Name
Column {
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "TITLE / NAME *"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
id: formNameField
width: parent.width
placeholderText: "e.g. GitHub, Google, Work Server..."
text: root.formName
onTextChanged: root.formName = text
}
}
// FIELD: Folder -- expandable list rather than a wrapping row of
// buttons, which grew unreadable once a vault had more than a few.
Column {
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "FOLDER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
Button {
width: parent.width
text: Model.plainLabel(root.formFolderLabel())
iconText: root.formPicker === "folder" ? "\u{F0140}" : "\u{F024B}"
selected: root.formPicker === "folder"
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
leftAlign: true
onClicked: root.toggleFormPicker("folder")
}
Flickable {
id: folderPickList
visible: root.formPicker === "folder"
width: parent.width
height: visible ? Math.min(Style.space(150), folderPickCol.implicitHeight) : 0
contentWidth: width
contentHeight: folderPickCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: folderPickList }
Column {
id: folderPickCol
width: folderPickList.width - root.scrollGutter
spacing: Style.space(2)
FormPickerRow {
width: parent.width
foreground: root.fg
fontFamily: root.fontFamily
label: "No Folder"
glyph: "\u{F0256}"
picked: !root.formFolderId
onActivated: root.setFormFolder("")
}
Repeater {
model: root.folders
delegate: FormPickerRow {
required property var modelData
width: parent.width
foreground: root.fg
fontFamily: root.fontFamily
label: modelData.name
glyph: "\u{F024B}"
picked: root.formFolderId === modelData.id
onActivated: root.setFormFolder(modelData.id)
}
}
}
}
// Creating a folder here saves leaving the form to make one.
Row {
width: parent.width
spacing: Style.space(6)
TextField {
width: parent.width - Style.space(96)
placeholderText: "New folder name..."
text: root.newFolderName
onTextChanged: root.newFolderName = text
onAccepted: root.submitNewFolder()
enabled: !root.creatingFolder
}
Button {
text: root.creatingFolder ? "Adding..." : "Add"
iconText: root.creatingFolder ? "\u{F0450}" : "\u{F0415}"
iconSpinning: root.creatingFolder
fontFamily: root.fontFamily
fontSize: Style.font.caption
enabled: !root.creatingFolder && root.newFolderName.trim() !== ""
onClicked: root.submitNewFolder()
}
}
}
// FIELD: Organization, and the collections it files items into.
Column {
visible: root.organizations.length > 0
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "ORGANIZATION"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
Button {
width: parent.width
text: Model.plainLabel(root.formOrgLabel())
iconText: root.formPicker === "organization" ? "\u{F0140}" : "\u{F0991}"
selected: root.formPicker === "organization"
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
leftAlign: true
onClicked: root.toggleFormPicker("organization")
}
Flickable {
id: orgPickList
visible: root.formPicker === "organization"
width: parent.width
height: visible ? Math.min(Style.space(150), orgPickCol.implicitHeight) : 0
contentWidth: width
contentHeight: orgPickCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: orgPickList }
Column {
id: orgPickCol
width: orgPickList.width - root.scrollGutter
spacing: Style.space(2)
FormPickerRow {
width: parent.width
foreground: root.fg
fontFamily: root.fontFamily
label: "My Vault"
glyph: "\u{F0004}"
picked: !root.formOrgId || root.formOrgId === "personal"
onActivated: root.setFormOrganization("")
}
Repeater {
model: root.organizations
delegate: FormPickerRow {
required property var modelData
width: parent.width
foreground: root.fg
fontFamily: root.fontFamily
label: modelData.name
glyph: "\u{F0991}"
picked: root.formOrgId === modelData.id
onActivated: root.setFormOrganization(modelData.id)
}
}
}
}
// Collections only exist for org-owned items, and Bitwarden
// requires at least one, so this appears with the choice.
Column {
visible: Boolean(root.formOrgId) && root.formOrgId !== "personal"
width: parent.width
spacing: Style.space(3)
Item { width: 1; height: Style.space(4) }
Row {
width: parent.width
spacing: Style.space(6)
Text {
textFormat: Text.PlainText
text: "COLLECTIONS"
color: root.formCollectionIds.length === 0 ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
Text {
textFormat: Text.PlainText
text: root.formCollectionsLoading
? "loading..."
: (root.formCollectionIds.length === 0
? "pick at least one"
: root.formCollectionIds.length + " selected")
color: root.formCollectionIds.length === 0 ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Flickable {
id: collectionList
width: parent.width
height: Math.min(Style.space(150), collectionCol.implicitHeight)
contentWidth: width
contentHeight: collectionCol.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded }
WheelScroll { view: collectionList }
Column {
id: collectionCol
width: collectionList.width - root.scrollGutter
spacing: Style.space(2)
Text {
textFormat: Text.PlainText
visible: !root.formCollectionsLoading && root.formCollections.length === 0
width: parent.width
text: "No collections available in this organization."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Repeater {
model: root.formCollections
delegate: FormPickerRow {
required property var modelData
width: parent.width
foreground: root.fg
fontFamily: root.fontFamily
label: modelData.name
glyph: "\u{F0290}"
picked: root.isFormCollectionSelected(modelData.id)
// Several collections may hold one item, so these
// toggle instead of replacing the choice.
multi: true
onActivated: root.toggleFormCollection(modelData.id)
}
}
}
}
}
}
// FIELD: Username (Login only)
Column {
visible: root.formTypeCode === 1
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "USERNAME / EMAIL"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "username or email address..."
text: root.formUsername
onTextChanged: root.formUsername = text
}
}
// FIELD: Password with Generator (Login only)
Column {
visible: root.formTypeCode === 1
width: parent.width
spacing: Style.space(3)
RowLayout {
width: parent.width
Text { textFormat: Text.PlainText; text: "PASSWORD"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
Item { Layout.fillWidth: true }
// Opens the real generator, which fills this field in and
// comes back. The ellipsis says it goes somewhere first.
Button {
text: "Generate..."
iconText: "󰌆"
fontFamily: root.fontFamily
fontSize: Style.font.caption
onClicked: root.openGenerator()
}
}
Row {
width: parent.width
spacing: Style.space(6)
TextField {
id: formPassField
width: parent.width - eyeBtnForm.width - Style.space(6)
placeholderText: "Password..."
password: !root.formPasswordRevealed
text: root.formPassword
onTextChanged: root.formPassword = text
}
Button {
id: eyeBtnForm
iconText: root.formPasswordRevealed ? "󰈉" : "󰈈"
tooltipText: root.formPasswordRevealed ? "Hide password" : "Show password"
fontFamily: root.fontFamily
onClicked: root.formPasswordRevealed = !root.formPasswordRevealed
}
}
}
// FIELD: TOTP Authenticator Key (Login only)
Column {
visible: root.formTypeCode === 1
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "AUTHENTICATOR KEY (TOTP SECRET)"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "e.g. JBSWY3DPEHPK3PXP (optional)..."
text: root.formTotp
onTextChanged: root.formTotp = text
}
}
// FIELD: Website URL (Login only)
Column {
visible: root.formTypeCode === 1
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "WEBSITE URL"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "https://example.com/login..."
text: root.formUri
onTextChanged: root.formUri = text
}
}
// -----------------------------------------------------------
// FORM FIELDS: Card
// -----------------------------------------------------------
// Expiry is split here, unlike the detail view, because these
// are two values the vault stores separately and a single box
// would have to guess where the boundary between them falls.
Column {
visible: root.formTypeCode === 3
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "CARDHOLDER NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "Name as printed on the card"
text: root.formCardholderName
onTextChanged: root.formCardholderName = text
}
}
Column {
visible: root.formTypeCode === 3
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "BRAND"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "Visa, Mastercard, Amex..."
text: root.formCardBrand
onTextChanged: root.formCardBrand = text
}
}
Column {
visible: root.formTypeCode === 3
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "CARD NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "1234 5678 9012 3456"
text: root.formCardNumber
onTextChanged: root.formCardNumber = text
}
}
Column {
visible: root.formTypeCode === 3
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "EXPIRY MONTH"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "MM"
text: root.formCardExpMonth
onTextChanged: root.formCardExpMonth = text
}
}
Column {
visible: root.formTypeCode === 3
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "EXPIRY YEAR"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "YYYY"
text: root.formCardExpYear
onTextChanged: root.formCardExpYear = text
}
}
Column {
visible: root.formTypeCode === 3
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "SECURITY CODE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "CVV / CVC"
text: root.formCardCode
onTextChanged: root.formCardCode = text
}
}
// -----------------------------------------------------------
// FORM FIELDS: Identity
// -----------------------------------------------------------
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "TITLE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "Mr, Ms, Dr..."
text: root.formIdTitle
onTextChanged: root.formIdTitle = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "FIRST NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdFirstName
onTextChanged: root.formIdFirstName = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "MIDDLE NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdMiddleName
onTextChanged: root.formIdMiddleName = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "LAST NAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdLastName
onTextChanged: root.formIdLastName = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "USERNAME"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdUsername
onTextChanged: root.formIdUsername = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "COMPANY"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdCompany
onTextChanged: root.formIdCompany = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "EMAIL"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "name@example.com"
text: root.formIdEmail
onTextChanged: root.formIdEmail = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "PHONE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdPhone
onTextChanged: root.formIdPhone = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "SOCIAL SECURITY NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdSsn
onTextChanged: root.formIdSsn = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "PASSPORT NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdPassport
onTextChanged: root.formIdPassport = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "LICENCE NUMBER"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdLicense
onTextChanged: root.formIdLicense = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "ADDRESS LINE 1"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdAddress1
onTextChanged: root.formIdAddress1 = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "ADDRESS LINE 2"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdAddress2
onTextChanged: root.formIdAddress2 = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "ADDRESS LINE 3"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdAddress3
onTextChanged: root.formIdAddress3 = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "CITY / TOWN"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdCity
onTextChanged: root.formIdCity = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "STATE / COUNTY"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdState
onTextChanged: root.formIdState = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "POSTAL CODE"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdPostalCode
onTextChanged: root.formIdPostalCode = text
}
}
Column {
visible: root.formTypeCode === 4
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "COUNTRY"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: ""
text: root.formIdCountry
onTextChanged: root.formIdCountry = text
}
}
// FIELD: Notes
Column {
width: parent.width
spacing: Style.space(3)
Text { textFormat: Text.PlainText; text: "NOTES"; color: root.dim; font.family: root.fontFamily; font.pixelSize: Style.font.caption; font.bold: true }
TextField {
width: parent.width
placeholderText: "Additional secure notes..."
text: root.formNotes
onTextChanged: root.formNotes = text
}
}
// Favorite Star Toggle
Row {
spacing: Style.space(8)
Button {
text: root.formFavorite ? "★ In Favorites" : "☆ Add to Favorites"
selected: root.formFavorite
accent: Color.accent
fontFamily: root.fontFamily
fontSize: Style.font.bodySmall
onClicked: root.formFavorite = !root.formFavorite
}
}
// Enter saves from anywhere in the form, so a long item does not
// have to be scrolled to the bottom to be committed.
//
// A Shortcut rather than `onAccepted` on each field: there are
// more than thirty of them and the next one added would silently
// not save. It is scoped tightly instead -- only on this screen,
// and not while a picker is open, where Enter belongs to the
// list being picked from.
Shortcut {
sequences: ["Return", "Enter"]
enabled: root.activeScreen === "edit" && root.formPicker === ""
onActivated: root.saveItemForm()
}
// Save Action Button
Button {
width: parent.width
text: root.isLoading
? "Saving..."
: (root.formIsEditing ? "Save Changes (Enter)" : "Create Item (Enter)")
iconText: root.isLoading ? "󰑐" : "󰄬"
iconSpinning: root.isLoading
selected: true
accent: Color.accent
fontFamily: root.fontFamily
enabled: !root.isLoading
onClicked: root.saveItemForm()
}
Item { height: Style.space(12); width: 1 }
}
}
}
}
// Transient updates belong to the panel, but not to its layout. Keeping
// this beside mainColumn means fittedContentHeight never sees it, so an
// unlock, copy, save, or error cannot shove the active screen down and
// pull it back up when the message clears.
StatusNotice {
id: statusNotice
statusMessage: root.flashMessage
errorMessage: root.errorMessage
statusSuppressed: root.totpFollowupActive
foreground: root.fg
surfaceColor: root.bar ? root.bar.background : Color.background
accentColor: root.accent
urgentColor: root.urgent
fontFamily: root.fontFamily
actionLabel: root.failedSave ? "Reopen " + root.failedSave.name : ""
onActionRequested: root.reopenFailedSave()
onErrorDismissed: {
// Dismissing the message drops the recovery with it: the list is
// already back to what the vault holds, so what is being discarded
// is the attempt, and leaving a Reopen behind an invisible message
// would be a button for something the user has said they are done
// with.
root.failedSave = null
root.errorMessage = ""
}
}
}
}
}