diff --git a/bin/rofi/bwmenu b/bin/rofi/bwmenu
new file mode 100755
index 00000000..165ea905
--- /dev/null
+++ b/bin/rofi/bwmenu
@@ -0,0 +1,257 @@
+#!/usr/bin/env cached-nix-shell
+#! nix-shell -p ruby_2_7 -i "ruby -S"
+
+# A rofi client for bitwarden. Similar to passmenu, but uses rofi. (Badly)
+# written in ruby because data persistence and frontend logic would be a pain to
+# write in bash or zsh. I'll refactor this later perhaps. Maybe.
+
+require 'shellwords'
+require 'json'
+require 'optparse'
+require 'uri'
+require 'base64'
+require 'fileutils'
+
+PersistentData = Struct.new(:path, :ttl, :pid, :data) do
+ DATADIR = "#{ENV['XDG_DATA_HOME']}/bwmenu"
+
+ def self.load name, ttl: nil
+ path = "#{DATADIR}/#{name}"
+ data = JSON::load(Base64.decode64(File.read(path))) if File.exists?(path)
+ d = PersistentData.new *(data || {}).values
+ d.path = path
+ d.ttl = ttl
+ d
+ end
+
+ def expire
+ File.delete(self.path) if File.exists?(self.path)
+ end
+
+ def save data = nil, onfinish = nil
+ self.data = data if data
+ FileUtils.mkdir_p DATADIR unless File.directory?(DATADIR)
+ File.open(self.path, 'w') do |f|
+ f.chmod 0600
+ f.write Base64.encode64(self.to_h.to_json)
+ end
+ if self.pid and system("ps -f p #{self.pid}")
+ Process.kill self.pid
+ end
+ if self.ttl
+ self.pid = fork do
+ Signal.trap('TERM') { throw :terminate }
+ catch :terminate do
+ sleep self.ttl
+ end
+ onfinish.call(self, data) if onfinish.respond_to? :call
+ self.expire
+ end
+ end
+ end
+end
+
+module BW
+ extend self
+
+ EXE = {}
+ def self.bin exe
+ EXE[exe] = `which #{exe} 2>/dev/null`.rstrip unless EXE[exe]
+ raise "Couldn't find #{exe} in PATH" if EXE[exe].empty?
+ EXE[exe]
+ end
+
+ def self.sh *args, input: nil, sudo: false, nooutput: false
+ exe, *args = args.flatten.compact.map(&:to_s)
+ bin exe
+ cmd = [sudo ? "sudo" : nil, exe, *args.shelljoin].compact.join(' ')
+ $stderr.puts "$ #{cmd}"
+ if input
+ IO.popen(cmd, "r+") do |pipe|
+ pipe.puts input
+ pipe.close_write
+ (pipe.gets || "").rstrip unless nooutput
+ end
+ else
+ (`#{cmd}` || "").rstrip
+ end
+ end
+
+ def self.rofi items, *args
+ input = items.keys.join("\n") if items
+ result = sh(['rofi', '-dmenu', '-markup-rows',
+ '-theme', 'theme/passmenu.rasi',
+ *args],
+ input: input)
+ if items
+ items[result]
+ else
+ result
+ end
+ end
+
+ def self.notify message, title: nil, urgency: "normal"
+ sh 'notify-send', '-u', urgency, '-a', "Bitwarden", *[title, message].compact
+ end
+
+ def self.error message
+ $stderr.puts message
+ notify message, urgency: 'low'
+ exit 1
+ end
+
+ def self.bw *args
+ sh [:bw, '--session', @@session_id, *args]
+ end
+
+ def self.xclip *args, **opts
+ sh :xclip, '-selection', 'clipboard', *args, **opts
+ end
+
+ @@session = nil
+ @@session_id = nil
+ def self.run argv
+ opts = {}
+ OptionParser.new do |opt|
+ opt.banner = "Usage: bwmenu [options] [URL]"
+ opt.on '--autolock TTL', "How long to keep the vault open"
+ opt.on '--autoclear TTL' "How long to keep secrets in the clipboard"
+ opt.on '--debug', "Be more verbose"
+ opt.on '-f', '--field FIELD', "Extract specific field from selected entry; accepts 'whole' for whole entry"
+ opt.on '-o', '--output', "Write output to stdout rather than the clipboard"
+ opt.on '--select', "Select first entry"
+ opt.on '--select-if-only', "Select first entry if only entry"
+ opt.on '--default ID', "Highlight item with ID by default"
+ opt.on '-r', "Resume where you last left off"
+ end.parse! argv, into: opts
+
+ clipboard_ttl = opts[:autoclear] || 20
+ vault_ttl = opts[:autolock] || 900
+
+ @@session = PersistentData.load :session, ttl: vault_ttl
+ if @@session.data
+ sid, data = @@session.data
+ else
+ pass = rofi nil, %w{-p Master\ Password -password -lines 0}
+ error "No password given. Aborting..." if pass.empty?
+ sid = sh [:bw, :unlock, '--raw', pass]
+ error "Failed to unlock vault. Aborting..." unless $?.success?
+ @@session_id = sid
+ data = JSON.parse(bw(:list, :items))
+ @@session.save [sid, data]
+ end
+
+ last = PersistentData.load :last_id
+ last_id, last_field = last.data if last
+ if opts[:r] and last_id.data
+ selected = data.find { |u| u['id'] == last_id }
+ else
+ select_type = opts[:"select-if-only"] ? :if_only : opts[:select]
+ selected = list_items data, argv.first,
+ default: opts[:default],
+ select: select_type
+ end
+
+ begin
+ error "Nothing selected. Aborting..." if selected.empty?
+ last_id = selected['id']
+ item = show_item selected, field: opts[:field]
+ if not item
+ error "Nothing selected. Aborting..." if selected.empty?
+ elsif item.respond_to? :call
+ item.call
+ elsif opts[:output]
+ puts item.rstrip
+ else
+ notify "Copied to clipboard for #{clipboard_ttl} seconds"
+ xclip '-in', input: item.rstrip, nooutput: true
+ clipboard = PersistentData.load :clipboard, ttl: clipboard_ttl
+ clipboard.save(
+ item.rstrip,
+ lambda { |_, data| xclip('-in', '/dev/null') if item.rstrip == xclip('-out') }
+ )
+ end
+ ensure
+ last.save [ last_id, last_field ]
+ end
+ end
+
+ #
+ def list_items data, query = nil, default: nil, select: false
+ rofiData = {}
+ rofiData["Sync Vault"] = lambda { notify "Sync vault", bw(:sync) },
+ rofiData["Open Vault in Browser"] = lambda { sh ENV['BROWSER'], bw(:config, :server) }
+ data = data.map do |i|
+ default_id = i['id'] if i['id'] == default
+ icon = ["", "", "", ""][i['type'] - 1]
+ case i['type']
+ when 1 # logins
+ urls = (i['login']['uris'] || []).filter_map do |u|
+ return unless u['uri']
+ uri = URI(u['uri'])
+ not i['name'].include?((uri.hostname || uri.path).downcase) and
+ uri.hostname || uri.path
+ end
+ subtitle = urls.join ", " if i['type'] == 1
+ subtitle = " (#{subtitle})" unless urls.empty?
+ when 2 # secure notes
+ when 3 # cards
+ subtitle = " (#{i['card']['brand'].downcase})"
+ when 4 # identities
+ end
+ ["#{icon} #{i['name']}#{subtitle}", i]
+ end
+ case select
+ when :if_only
+ return data[0][1] if data.length == 1
+ when true
+ return data[0][1]
+ end
+ rofi rofiData.merge(data.to_h), '-p', 'Bitwarden', '-filter', (query || "")
+ end
+
+ def show_item item, field: nil
+ preamble = {}
+ data = {}
+ case item['type']
+ when 1 # logins
+ url = item['login']['uris'][0]['uri']
+ preamble[" Launch #{url}"] = lambda { sh ENV['BROWSER'], url }
+ data['Username'] = item['login']['username']
+ data['Password'] = item['login']['password']
+ data['TOTP'] = bw :get, :totp, item['id'] if item['totp']
+ return (data[field.capitalize] || item[field]) if field
+ when 2 # secure notes
+ data['Notes'] = item['notes']
+ return item[field] if field
+ when 3 # cards
+ cardInfo = item['card']
+ data['Card Type'] = cardInfo['brand']
+ data['Card Name'] = cardInfo['cardholderName']
+ data['Card Number'] = cardInfo['number']
+ data['Expiry Date'] = "#{cardInfo['expMonth']}/#{cardInfo['expYear']}"
+ data['Security Code'] = cardInfo['code']
+ return cardInfo[field] if field
+ when 4 # identities
+ item['identity'].each { |k,v| data[k] = v if v }
+ return item['identity'][field] if field
+ end
+ (item['fields'] || []).each do |field|
+ data[field['name']] = field['value']
+ end
+ maxLength = data.keys.map(&:length).max + 5
+ rofiData = {}
+ preamble.merge(data).each do |label, value|
+ if value.kind_of? String
+ valueLabel = value
+ valueLabel = value.gsub /./, '*' if label == 'Password'
+ rofiData["%-#{maxLength}s %s" % [label, valueLabel]] = value
+ else
+ rofiData[label] = value
+ end
+ end
+ rofi rofiData, '-p', item['name']
+ end
+end
+
+BW.run ARGV.dup
diff --git a/config/sxhkd/sxhkdrc b/config/sxhkd/sxhkdrc
index 957e9bdf..8bbfe504 100644
--- a/config/sxhkd/sxhkdrc
+++ b/config/sxhkd/sxhkdrc
@@ -37,8 +37,11 @@ super + space
super + Tab
$DOTFILES/bin/rofi/windowmenu
-super + backslash
- $DOTFILES/bin/rofi/passmenu
+super + p
+ $DOTFILES/bin/rofi/bwmenu
+
+super + shift + p
+ $DOTFILES/bin/rofi/bwmenu -r
super + slash
$DOTFILES/bin/rofi/filemenu -x