ruby-pass-qt 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # Pass-Qt
2
+
3
+ A simple GUI for pass on Linux.
4
+
5
+ ## Requirements
6
+
7
+ - [Ruby 3.4+](https://www.ruby-lang.org/)
8
+ - [Qt 6.10+](https://www.qt.io/)
9
+ - [pass](https://www.passwordstore.org/)
10
+ - [pwgen](https://sourceforge.net/projects/pwgen/)
11
+
12
+ ## Installation
13
+
14
+ ```console
15
+ $ gem install pass-qt
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ To initialize the password store:
21
+
22
+ ```console
23
+ $ pass init new_gpg-id_or_email
24
+ ```
25
+
26
+ To launch the GUI:
27
+
28
+ ```console
29
+ $ pass-qt
30
+ ```
31
+
32
+ ## Screenshot
33
+
34
+ ![screenshot](https://github.com/souk4711/ruby-pass-qt/raw/main/screenshot.png)
35
+
36
+ ## License
37
+
38
+ Licensed under the [GPL-3.0-only](./LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ _ = Gem::Specification.load("pass-qt.gemspec")
5
+
6
+ require "rubocop/rake_task"
7
+ RuboCop::RakeTask.new
8
+
9
+ require "rspec/core/rake_task"
10
+ RSpec::Core::RakeTask.new(:spec)
11
+
12
+ task default: %i[spec]
data/exe/pass-qt ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pass-qt"
5
+
6
+ PassQt::Application.run
@@ -0,0 +1,235 @@
1
+ module PassQt
2
+ class PassInfoWidget < RubyQt6::Bando::QWidget
3
+ q_object do
4
+ slot "_on_copy_action_triggered()"
5
+ slot "_on_copy_action_triggered_otpcode()"
6
+ slot "_on_view_action_triggered()"
7
+ end
8
+
9
+ def initialize
10
+ super
11
+
12
+ @store = QString.new
13
+ @passname = QString.new
14
+
15
+ initialize_form
16
+ initialize_otpform
17
+ initialize_folderform
18
+ initialize_infoframe
19
+
20
+ @stackedlayout = QStackedLayout.new
21
+ @stackedlayout.add_widget(@form)
22
+ @stackedlayout.add_widget(@otpform)
23
+ @stackedlayout.add_widget(@folderform)
24
+ @stackedlayout.add_widget(@infoframe)
25
+
26
+ mainlayout = QVBoxLayout.new(self)
27
+ mainlayout.add_spacing(76)
28
+ mainlayout.add_layout(@stackedlayout)
29
+
30
+ use_infoframe
31
+ end
32
+
33
+ def reinitialize_passfile(store, passname)
34
+ @store = store
35
+ @passname = passname
36
+ use_infoframe
37
+
38
+ Pass.show(@store, @passname, on_success: ->(data) {
39
+ formdata = h_parse_passfile(data)
40
+ if formdata["password"].start_with?("otpauth:")
41
+ use_otpform(formdata)
42
+ else
43
+ use_form(formdata)
44
+ end
45
+ }, on_failure: ->(data) {
46
+ errinfo = data["stderr"]
47
+ use_infoframe_err(errinfo)
48
+ })
49
+ end
50
+
51
+ def reinitialize_passfolder(store, passname)
52
+ @store = store
53
+ @passname = passname
54
+ use_infoframe
55
+ use_folderform
56
+ end
57
+
58
+ def reinitialize_infoframe
59
+ @store = QString.new
60
+ @passname = QString.new
61
+ use_infoframe
62
+ end
63
+
64
+ private
65
+
66
+ def initialize_form
67
+ @passnameinput = initialize_form_inputfield
68
+ initialize_form_inputfield_copyaction(@passnameinput)
69
+
70
+ @passwordinput = initialize_form_inputfield
71
+ initialize_form_inputfield_copyaction(@passwordinput)
72
+ initialize_form_inputfield_viewaction(@passwordinput)
73
+
74
+ @usernameinput = initialize_form_inputfield
75
+ initialize_form_inputfield_copyaction(@usernameinput)
76
+
77
+ @websiteinput = initialize_form_inputfield
78
+ initialize_form_inputfield_copyaction(@websiteinput)
79
+
80
+ @form = QWidget.new
81
+ formlayout = QFormLayout.new(@form)
82
+ formlayout.add_row(initialize_form_label("File"), @passnameinput)
83
+ formlayout.add_row(initialize_form_label("Username"), @usernameinput)
84
+ formlayout.add_row(initialize_form_label("Password"), @passwordinput)
85
+ formlayout.add_row(initialize_form_label("Website"), @websiteinput)
86
+ end
87
+
88
+ def initialize_otpform
89
+ @otppassnameinput = initialize_form_inputfield
90
+ initialize_form_inputfield_copyaction(@otppassnameinput)
91
+
92
+ @otppasswordinput = initialize_form_inputfield
93
+ initialize_form_inputfield_copyaction(@otppasswordinput)
94
+ initialize_form_inputfield_viewaction(@otppasswordinput)
95
+
96
+ @otpcodeinput = initialize_form_inputfield
97
+ action = @otpcodeinput.add_action(QIcon.from_theme(QIcon::ThemeIcon::EditCopy), QLineEdit::LeadingPosition)
98
+ action.triggered.connect(self, :_on_copy_action_triggered_otpcode)
99
+
100
+ @otpform = QWidget.new
101
+ otpformlayout = QFormLayout.new(@otpform)
102
+ otpformlayout.add_row(initialize_form_label("File"), @otppassnameinput)
103
+ otpformlayout.add_row(initialize_form_label("OTP URI"), @otppasswordinput)
104
+ otpformlayout.add_row(initialize_form_label("OTP Code"), @otpcodeinput)
105
+ end
106
+
107
+ def initialize_folderform
108
+ @folderpassnameinput = initialize_form_inputfield
109
+ initialize_form_inputfield_copyaction(@folderpassnameinput)
110
+
111
+ @folderform = QWidget.new
112
+ folderformlayout = QFormLayout.new(@folderform)
113
+ folderformlayout.add_row(initialize_form_label("Folder"), @folderpassnameinput)
114
+ end
115
+
116
+ def initialize_form_label(text)
117
+ label = QLabel.new(text)
118
+ label.set_alignment(Qt::AlignRight)
119
+ label.set_style_sheet("min-width: 80px; padding-right: 4px;")
120
+ label
121
+ end
122
+
123
+ def initialize_form_inputfield
124
+ input = QLineEdit.new
125
+ input.set_read_only(true)
126
+ input.set_focus_policy(Qt::NoFocus)
127
+ input
128
+ end
129
+
130
+ def initialize_form_inputfield_copyaction(input)
131
+ action = input.add_action(QIcon.from_theme(QIcon::ThemeIcon::EditCopy), QLineEdit::LeadingPosition)
132
+ action.triggered.connect(self, :_on_copy_action_triggered)
133
+ end
134
+
135
+ def initialize_form_inputfield_viewaction(input)
136
+ action = input.add_action(QIcon.from_theme(QIcon::ThemeIcon::DocumentPrintPreview), QLineEdit::TrailingPosition)
137
+ action.triggered.connect(self, :_on_view_action_triggered)
138
+ end
139
+
140
+ def initialize_infoframe
141
+ @infolabel = QLabel.new
142
+ @infoframe = QWidget.new
143
+ infoframelayout = QVBoxLayout.new(@infoframe)
144
+ infoframelayout.add_widget(@infolabel)
145
+ infoframelayout.add_stretch
146
+ end
147
+
148
+ def use_form(formdata)
149
+ @passnameinput.set_text(@passname)
150
+ @passwordinput.set_text(formdata["password"])
151
+ @passwordinput.set_echo_mode(QLineEdit::Password)
152
+
153
+ @usernameinput.set_text(formdata["username"])
154
+ @websiteinput.set_text(formdata["website"])
155
+ @stackedlayout.set_current_widget(@form)
156
+ end
157
+
158
+ def use_otpform(formdata)
159
+ @otppassnameinput.set_text(@passname)
160
+ @otppasswordinput.set_text(formdata["password"])
161
+ @otppasswordinput.set_cursor_position(0)
162
+ @otppasswordinput.set_echo_mode(QLineEdit::Password)
163
+
164
+ @otpcodeinput.set_text("")
165
+ @stackedlayout.set_current_widget(@otpform)
166
+
167
+ Pass.otp(@store, @passname, on_success: ->(data) {
168
+ otpcode = data["stdout"].rstrip
169
+ @otpcodeinput.set_text(otpcode)
170
+ }, on_failure: ->(_) {})
171
+ end
172
+
173
+ def use_folderform
174
+ @folderpassnameinput.set_text(@passname)
175
+ @stackedlayout.set_current_widget(@folderform)
176
+ end
177
+
178
+ def use_infoframe(info = "")
179
+ @infolabel.set_style_sheet("color: black;")
180
+ @infolabel.set_text(info)
181
+ @stackedlayout.set_current_widget(@infoframe)
182
+ end
183
+
184
+ def use_infoframe_err(info)
185
+ @infolabel.set_style_sheet("color: red;")
186
+ @infolabel.set_text(info)
187
+ @stackedlayout.set_current_widget(@infoframe)
188
+ end
189
+
190
+ def h_parse_passfile(data)
191
+ lines = data["stdout"].lines
192
+ password = lines[0][..-2]
193
+
194
+ username = ""
195
+ website = ""
196
+ lines.each do |line|
197
+ matched = line.match(/\A(\w+:\s*)?(.*)\n/)
198
+ next if matched.nil?
199
+
200
+ case matched[1]&.rstrip&.downcase
201
+ when "username:" then username = matched[2]
202
+ when "website:" then website = matched[2]
203
+ end
204
+ end
205
+
206
+ {
207
+ "password" => password,
208
+ "username" => username,
209
+ "website" => website
210
+ }
211
+ end
212
+
213
+ def _on_copy_action_triggered
214
+ input = sender.parent
215
+ QApplication.clipboard.set_text(input.text)
216
+ end
217
+
218
+ def _on_copy_action_triggered_otpcode
219
+ Pass.otp(@store, @passname, on_success: ->(data) {
220
+ otpcode = data["stdout"].rstrip
221
+ @otpcodeinput.set_text(otpcode)
222
+ QApplication.clipboard.set_text(otpcode)
223
+ }, on_failure: ->(_) {})
224
+ end
225
+
226
+ def _on_view_action_triggered
227
+ input = sender.parent
228
+ case input.echo_mode
229
+ when QLineEdit::Normal then input.set_echo_mode(QLineEdit::Password)
230
+ when QLineEdit::Password then input.set_echo_mode(QLineEdit::Normal)
231
+ else raise "unreachable!"
232
+ end
233
+ end
234
+ end
235
+ end
@@ -0,0 +1,240 @@
1
+ module PassQt
2
+ class PassListWidget < RubyQt6::Bando::QWidget
3
+ class TreeWidget < RubyQt6::Bando::QTreeWidget
4
+ DataItem = Struct.new(:passname, :treewidgetitem)
5
+
6
+ q_object do
7
+ signal "store_changed(QString)"
8
+ signal "passfile_selected(QString,QString)"
9
+ signal "passfolder_selected(QString,QString)"
10
+ slot "_on_item_clicked(QTreeWidgetItem*,int)"
11
+ slot "_on_new_password_action_triggered()"
12
+ slot "_on_new_otp_action_triggered()"
13
+ slot "_on_delete_action_triggered()"
14
+ slot "_on_refresh_action_triggered()"
15
+ slot "_on_open_action_triggered()"
16
+ end
17
+
18
+ def initialize
19
+ super
20
+
21
+ @store = QString.new
22
+ @dataitems = {}
23
+
24
+ initialize_actions
25
+ initialize_fileiconprovider
26
+
27
+ item_clicked.connect(self, :_on_item_clicked)
28
+ end
29
+
30
+ def context_menu_event(evt)
31
+ menu = QMenu.new("", self)
32
+
33
+ menu.add_action(@new_password_action)
34
+ menu.add_action(@new_otp_action)
35
+ menu.add_action(@delete_action)
36
+
37
+ menu.add_separator
38
+ menu.add_action(@refresh_action)
39
+ menu.add_action(@open_action)
40
+
41
+ enabled = !selected_items.empty?
42
+ @delete_action.set_enabled(enabled)
43
+
44
+ menu.exec(evt.global_pos)
45
+ end
46
+
47
+ def reinitialize_store(store)
48
+ clear
49
+
50
+ @store = store
51
+ @dataitems = {}
52
+
53
+ store_root_path = QDir.new(@store)
54
+ dirs = [store]
55
+ until dirs.empty?
56
+ dir = dirs.shift
57
+ diritem = @dataitems[dir]&.treewidgetitem || invisible_root_item
58
+
59
+ entry_list = QDir.new(dir).entry_info_list(QDir::Dirs | QDir::Files | QDir::NoDotAndDotDot)
60
+ entry_list.each do |entry|
61
+ filepath = entry.absolute_file_path
62
+ next if @dataitems.key?(filepath)
63
+
64
+ if entry.dir?
65
+ dirs << filepath
66
+ passname = store_root_path.relative_file_path(filepath)
67
+ elsif entry.file?
68
+ next unless h_passfile?(entry)
69
+ passname = store_root_path.relative_file_path(filepath)
70
+ passname = passname[0, passname.size - entry.suffix.size - 1]
71
+ else
72
+ next
73
+ end
74
+
75
+ item = QTreeWidgetItem.new(diritem, QStringList.new << entry.complete_base_name << filepath)
76
+ item.set_icon(0, @fileiconprovider.icon(entry))
77
+ @dataitems[filepath] = DataItem.new(passname, item)
78
+ end
79
+ end
80
+
81
+ expand_all
82
+ store_changed.emit(@store)
83
+ end
84
+
85
+ def update_passname_filter(text)
86
+ if text.empty?
87
+ @dataitems.each do |_, item|
88
+ item.treewidgetitem.set_hidden(false)
89
+ item.treewidgetitem.set_selected(false)
90
+ end
91
+ return
92
+ end
93
+
94
+ re_options = QRegularExpression::UnanchoredWildcardConversion | QRegularExpression::NonPathWildcardConversion
95
+ re = QRegularExpression.from_wildcard(text, nil, re_options)
96
+
97
+ filepath_matched = Set.new
98
+ filepath_matched_1st = nil
99
+ @dataitems.each do |filepath, item|
100
+ has_match = re.match(item.passname).has_match
101
+ next unless has_match
102
+
103
+ if filepath_matched_1st.nil?
104
+ filepath_matched_1st = filepath if h_passfile?(filepath)
105
+ end
106
+
107
+ loop do
108
+ filepath_matched << filepath
109
+ filepath = QFileInfo.new(filepath).absolute_path
110
+ break unless @dataitems.key?(filepath)
111
+ end
112
+ end
113
+
114
+ @dataitems.each do |filepath, item|
115
+ visible = filepath_matched.include?(filepath)
116
+ item.treewidgetitem.set_hidden(!visible)
117
+
118
+ selected = filepath_matched_1st == filepath
119
+ item.treewidgetitem.set_selected(selected)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ def initialize_actions
126
+ @new_password_action = initialize_actions_act(QIcon::ThemeIcon::DocumentNew, "New Password", :_on_new_password_action_triggered)
127
+ @new_otp_action = initialize_actions_act(QIcon::ThemeIcon::DocumentNew, "New OTP", :_on_new_otp_action_triggered)
128
+ @delete_action = initialize_actions_act(QIcon::ThemeIcon::EditDelete, "Delete", :_on_delete_action_triggered)
129
+ @refresh_action = initialize_actions_act(QIcon::ThemeIcon::ViewRefresh, "Refresh", :_on_refresh_action_triggered)
130
+ @open_action = initialize_actions_act(QIcon::ThemeIcon::FolderVisiting, "Open Store With File Explorer", :_on_open_action_triggered)
131
+ end
132
+
133
+ def initialize_actions_act(icon, text, slot)
134
+ action = QAction.new(QIcon.from_theme(icon), text, self)
135
+ action.triggered.connect(self, slot)
136
+ action
137
+ end
138
+
139
+ def initialize_fileiconprovider
140
+ @fileiconprovider = QFileIconProvider.new
141
+ end
142
+
143
+ def reinitialize(selected_passname: "")
144
+ reinitialize_store(@store)
145
+
146
+ @dataitems.each do |_, item|
147
+ if item.passname == selected_passname
148
+ item.treewidgetitem.set_selected(true)
149
+ break
150
+ end
151
+ end
152
+ end
153
+
154
+ def h_passfile?(fileinfo)
155
+ case fileinfo
156
+ when QFileInfo then fileinfo.suffix.downcase == "gpg"
157
+ when QString then fileinfo.ends_with(".gpg", Qt::CaseInsensitive)
158
+ else raise "unreachable!"
159
+ end
160
+ end
161
+
162
+ def h_folderpath(fileinfo)
163
+ folder = fileinfo.dir? ? fileinfo.absolute_file_path : fileinfo.absolute_path
164
+ QDir.new(@store).relative_file_path(folder)
165
+ end
166
+
167
+ def _on_item_clicked(item, _column)
168
+ filepath = item.data(1, Qt::DisplayRole).value
169
+ dataitem = @dataitems[filepath]
170
+ h_passfile?(filepath) ?
171
+ passfile_selected.emit(@store, dataitem.passname) :
172
+ passfolder_selected.emit(@store, dataitem.passname)
173
+ end
174
+
175
+ def _on_new_password_action_triggered
176
+ item = selected_items[0]
177
+ if item
178
+ filepath = item.data(1, Qt::DisplayRole).value
179
+ folder = h_folderpath(QFileInfo.new(filepath))
180
+ folder = "" if folder == "."
181
+ else
182
+ folder = ""
183
+ end
184
+
185
+ dialog = NewPasswordDialog.new(@store, folder, on_success: ->(passname) {
186
+ reinitialize(selected_passname: passname)
187
+ })
188
+ dialog.show
189
+ end
190
+
191
+ def _on_new_otp_action_triggered
192
+ item = selected_items[0]
193
+ if item
194
+ filepath = item.data(1, Qt::DisplayRole).value
195
+ folder = h_folderpath(QFileInfo.new(filepath))
196
+ folder = "" if folder == "."
197
+ else
198
+ folder = ""
199
+ end
200
+
201
+ dialog = NewOneTimePasswordDialog.new(@store, folder, on_success: ->(passname) {
202
+ reinitialize(selected_passname: passname)
203
+ })
204
+ dialog.show
205
+ end
206
+
207
+ def _on_delete_action_triggered
208
+ item = selected_items[0]
209
+ filepath = item.data(1, Qt::DisplayRole).value
210
+ dataitem = @dataitems[filepath]
211
+
212
+ message = "<p>Do you really want to delete this item?</p>#{dataitem.passname}"
213
+ reply = QMessageBox.question(self, "", message)
214
+ return if reply == QMessageBox::No
215
+
216
+ file = QFile.new(filepath)
217
+ file.move_to_trash
218
+ reinitialize
219
+ end
220
+
221
+ def _on_refresh_action_triggered
222
+ item = selected_items[0]
223
+ if item
224
+ filepath = item.data(1, Qt::DisplayRole).value
225
+ dataitem = @dataitems[filepath]
226
+ passname = dataitem.passname
227
+ else
228
+ passname = ""
229
+ end
230
+
231
+ reinitialize(selected_passname: passname)
232
+ end
233
+
234
+ def _on_open_action_triggered
235
+ url = QUrl.from_local_file(@store)
236
+ QDesktopServices.open_url(url)
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,91 @@
1
+ require_relative "passlistwidget/treewidget"
2
+
3
+ module PassQt
4
+ class PassListWidget < RubyQt6::Bando::QWidget
5
+ q_object do
6
+ signal "store_changed(QString)"
7
+ signal "passfile_selected(QString,QString)"
8
+ signal "passfolder_selected(QString,QString)"
9
+ slot "_on_combobox_current_text_changed(QString)"
10
+ slot "_on_searchbar_text_changed(QString)"
11
+ slot "_on_treewidget_store_changed(QString)"
12
+ end
13
+
14
+ def initialize
15
+ super
16
+
17
+ initialize_combobox
18
+ initialize_searchbar
19
+ initialize_treewidget
20
+
21
+ mainlayout = QVBoxLayout.new(self)
22
+ mainlayout.add_layout(@comboboxlayout)
23
+ mainlayout.add_widget(@searchbar)
24
+ mainlayout.add_widget(@treewidget)
25
+
26
+ update_combobox
27
+ end
28
+
29
+ def reinitialize_stores
30
+ @combobox.block_signals(true).tap do |blocked|
31
+ @combobox.clear
32
+ ensure
33
+ @combobox.block_signals(blocked)
34
+ end
35
+
36
+ update_combobox
37
+ end
38
+
39
+ private
40
+
41
+ def initialize_combobox
42
+ @combobox = QComboBox.new
43
+ @combobox.current_text_changed.connect(self, :_on_combobox_current_text_changed)
44
+
45
+ @comboboxlayout = QHBoxLayout.new
46
+ @comboboxlayout.add_widget(QLabel.new("Current Store"))
47
+ @comboboxlayout.add_widget(@combobox)
48
+ @comboboxlayout.set_stretch(0, 2)
49
+ @comboboxlayout.set_stretch(1, 3)
50
+ end
51
+
52
+ def initialize_searchbar
53
+ @searchbar = QLineEdit.new
54
+ @searchbar.set_clear_button_enabled(true)
55
+ @searchbar.set_placeholder_text("List passwords that match passname...")
56
+ @searchbar.text_changed.connect(self, :_on_searchbar_text_changed)
57
+ end
58
+
59
+ def initialize_treewidget
60
+ @treewidget = TreeWidget.new
61
+ @treewidget.set_header_hidden(true)
62
+ @treewidget.store_changed.connect(self, :_on_treewidget_store_changed)
63
+ @treewidget.passfile_selected.connect(self, :passfile_selected)
64
+ @treewidget.passfolder_selected.connect(self, :passfolder_selected)
65
+ end
66
+
67
+ def update_combobox
68
+ PassQt.settings.GET_stores.each do |store|
69
+ fileinfo = QFileInfo.new(store["fullpath"])
70
+ @combobox.add_item(fileinfo.file_name, QVariant.new(fileinfo.absolute_file_path))
71
+ end
72
+ end
73
+
74
+ def _on_combobox_current_text_changed(_text)
75
+ store = @combobox.current_data.value
76
+ @treewidget.reinitialize_store(store)
77
+
78
+ @searchbar.clear
79
+ QTimer.single_shot(0, @searchbar, :set_focus)
80
+ end
81
+
82
+ def _on_searchbar_text_changed(text)
83
+ @treewidget.update_passname_filter(text)
84
+ end
85
+
86
+ def _on_treewidget_store_changed(store)
87
+ @searchbar.clear
88
+ store_changed.emit(store)
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,2 @@
1
+ require_relative "components/passinfowidget"
2
+ require_relative "components/passlistwidget"