Add bin/rofi/bwmenu & sxhkd shortcuts for it
Bitwarden + rofi
This commit is contained in:
parent
4f4e0de6a3
commit
705a1fa30d
257
bin/rofi/bwmenu
Executable file
257
bin/rofi/bwmenu
Executable file
|
@ -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 = " <span size=\"x-small\">(#{subtitle})</span>" unless urls.empty?
|
||||
when 2 # secure notes
|
||||
when 3 # cards
|
||||
subtitle = " <span size=\"x-small\">(#{i['card']['brand'].downcase})</span>"
|
||||
when 4 # identities
|
||||
end
|
||||
["<span font_desc=\"Fira Code\" font_weight=\"bold\">#{icon} </span> <b>#{i['name']}</b>#{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["<span font_desc=\"Fira Code\" font_weight=\"bold\">%-#{maxLength}s</span> %s" % [label, valueLabel]] = value
|
||||
else
|
||||
rofiData[label] = value
|
||||
end
|
||||
end
|
||||
rofi rofiData, '-p', item['name']
|
||||
end
|
||||
end
|
||||
|
||||
BW.run ARGV.dup
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue