main.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. #!/usr/bin/python3
  2. # This file is part of PyPass
  3. #
  4. # PyPass is free software: you can redistribute it and/or modify
  5. # it under the terms of the GNU General Public License as published by
  6. # the Free Software Foundation, either version 3 of the License, or
  7. # (at your option) any later version.
  8. #
  9. # This program is distributed in the hope that it will be useful,
  10. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. # GNU General Public License for more details.
  13. #
  14. # You should have received a copy of the GNU General Public License
  15. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. import getopt
  17. import os
  18. import signal
  19. import sys
  20. import re
  21. import time
  22. from subprocess import Popen, PIPE
  23. from queue import Queue, Empty
  24. from PyQt5.QtCore import QStringListModel
  25. from PyQt5.QtWidgets import QApplication, QDialog, QVBoxLayout, QLabel, QTextEdit, QDialogButtonBox
  26. from PyQt5.Qt import QQmlApplicationEngine, QObject, QQmlProperty, QUrl
  27. class SignalHandler():
  28. def __init__(self, window):
  29. self.window = window
  30. def handle(self, signum, frame):
  31. # Signal received
  32. self.window.show()
  33. class ViewModel():
  34. def __init__(self):
  35. self.ANSIEscapeRegex = re.compile('(\x9B|\x1B\[)[0-?]*[ -\/]*[@-~]')
  36. # Temporary values to allow binding. These will be properly set when
  37. # possible and relevant.
  38. self.filteredList = []
  39. self.resultListModelList = QStringListModel()
  40. self.resultListModelMaxIndex = -1
  41. self.messageList = []
  42. self.messageListModelList = QStringListModel()
  43. self.chosenEntry = None
  44. self.chosenEntryList = []
  45. def bindContext(self, context, window, searchInputModel, resultListModel):
  46. self.context = context
  47. self.window = window
  48. self.searchInputModel = searchInputModel
  49. self.resultListModel = resultListModel
  50. def bindStore(self, store):
  51. self.store = store
  52. self.commandsText = self.store.getCommands()
  53. self.entryList = self.store.getEntries()
  54. self.search()
  55. def addError(self, message):
  56. for line in message.splitlines():
  57. if not line or line.isspace():
  58. continue
  59. self.messageList.append(["<font color='red'>{}</color>".format(line), time.time()])
  60. self.showMessages()
  61. def addMessage(self, message):
  62. for line in message.splitlines():
  63. if not line or line.isspace():
  64. continue
  65. self.messageList.append([line, time.time()])
  66. self.showMessages()
  67. def showMessages(self):
  68. messageListForModel = []
  69. for message in self.messageList:
  70. messageListForModel.append(message[0])
  71. self.messageListModelList = QStringListModel(messageListForModel)
  72. self.context.setContextProperty("messageListModelList", self.messageListModelList)
  73. def clearOldMessages(self):
  74. if len(self.messageList) == 0:
  75. return
  76. # Remove every error message older than 3 seconds and redraw the error list
  77. currentTime = time.time()
  78. self.messageList = [message for message in self.messageList if currentTime - message[1] < 3]
  79. self.showMessages()
  80. def goUp(self):
  81. if QQmlProperty.read(self.searchInputModel, "text") != "":
  82. QQmlProperty.write(self.searchInputModel, "text", "")
  83. return
  84. if self.chosenEntry == None:
  85. self.window.close()
  86. return
  87. self.chosenEntry = None
  88. self.search()
  89. def tabComplete(self):
  90. currentInput = QQmlProperty.read(self.searchInputModel, "text")
  91. stringToMatch = None
  92. for entry in self.filteredList:
  93. if entry in self.commandsText:
  94. continue
  95. if stringToMatch == None:
  96. stringToMatch = entry
  97. else:
  98. for i in range(0, len(stringToMatch)):
  99. if entry[i] != stringToMatch[i]:
  100. stringToMatch = stringToMatch[:i]
  101. break
  102. possibleCommand = currentInput.split(" ", 1)[0]
  103. output = stringToMatch
  104. for command in self.commandsText:
  105. if command.startswith(possibleCommand):
  106. output = possibleCommand + " " + stringToMatch
  107. break
  108. if len(output) <= len(currentInput):
  109. self.addError("No tab completion possible")
  110. return
  111. QQmlProperty.write(self.searchInputModel, "text", output)
  112. self.search()
  113. def search(self):
  114. if self.chosenEntry != None:
  115. self.searchChosenEntry()
  116. return
  117. currentIndex = QQmlProperty.read(self.resultListModel, "currentIndex")
  118. if currentIndex == -1 or len(self.filteredList) < currentIndex + 1:
  119. currentItem = None
  120. else:
  121. currentItem = self.filteredList[currentIndex]
  122. self.filteredList = []
  123. commandList = []
  124. searchStrings = QQmlProperty.read(self.searchInputModel, "text").lower().split(" ")
  125. for entry in self.entryList:
  126. if all(searchString in entry.lower() for searchString in searchStrings):
  127. self.filteredList.append(entry)
  128. self.resultListModelMaxIndex = len(self.filteredList) - 1
  129. self.context.setContextProperty("resultListModelMaxIndex", self.resultListModelMaxIndex)
  130. for command in self.commandsText:
  131. if searchStrings[0] in command:
  132. commandList.append(command)
  133. if len(self.filteredList) == 0 and len(commandList) > 0:
  134. self.filteredList = commandList
  135. for entry in self.entryList:
  136. if any(searchString in entry.lower() for searchString in searchStrings[1:]):
  137. self.filteredList.append(entry)
  138. else:
  139. self.filteredList += commandList
  140. self.resultListModelList = QStringListModel(self.filteredList)
  141. self.context.setContextProperty("resultListModel", self.resultListModelList)
  142. if self.resultListModelMaxIndex == -1:
  143. currentIndex = -1
  144. elif currentItem == None:
  145. currentIndex = 0
  146. else:
  147. try:
  148. currentIndex = self.filteredList.index(currentItem)
  149. except ValueError:
  150. currentIndex = 0
  151. QQmlProperty.write(self.resultListModel, "currentIndex", currentIndex)
  152. def searchChosenEntry(self):
  153. # Ensure this entry still exists
  154. if self.chosenEntry not in self.entryList:
  155. self.addError(self.chosenEntry + " is no longer available")
  156. self.chosenEntry = None
  157. QQmlProperty.write(self.searchInputModel, "text", "")
  158. self.search()
  159. return
  160. if len(self.filteredList) == 0:
  161. currentItem = None
  162. else:
  163. currentIndex = QQmlProperty.read(self.resultListModel, "currentIndex")
  164. currentItem = self.filteredList[currentIndex]
  165. searchStrings = QQmlProperty.read(self.searchInputModel, "text").lower().split(" ")
  166. self.filteredList = []
  167. for entry in self.chosenEntryList:
  168. if any(searchString in entry.lower() for searchString in searchStrings):
  169. self.filteredList.append(entry)
  170. try:
  171. currentIndex = self.filteredList.index(currentItem)
  172. except ValueError:
  173. currentIndex = 0
  174. self.resultListModelList = QStringListModel(self.filteredList)
  175. self.context.setContextProperty("resultListModel", self.resultListModelList)
  176. QQmlProperty.write(self.resultListModel, "currentIndex", currentIndex)
  177. def select(self):
  178. if self.chosenEntry != None:
  179. self.selectField()
  180. return
  181. if len(self.filteredList) == 0:
  182. return
  183. currentIndex = QQmlProperty.read(self.resultListModel, "currentIndex")
  184. if currentIndex == -1:
  185. commandTyped = QQmlProperty.read(self.searchInputModel, "text").split(" ")
  186. if commandTyped[0] not in self.store.getSupportedCommands():
  187. return
  188. result = self.store.runCommand(commandTyped, printOnSuccess=True)
  189. if result != None:
  190. QQmlProperty.write(self.searchInputModel, "text", "")
  191. return
  192. self.chosenEntry = self.filteredList[currentIndex]
  193. entryContent = self.store.getAllEntryFields(self.chosenEntry)
  194. if len(entryContent) == 1:
  195. self.store.copyEntryToClipboard(self.chosenEntry)
  196. self.window.close()
  197. return
  198. # The first line is most likely the password. Do not show this on the
  199. # screen
  200. entryContent[0] = "********"
  201. # If the password entry has more than one line, fill the result list
  202. # with all lines, so the user can choose the line they want to copy to
  203. # the clipboard
  204. self.chosenEntryList = entryContent
  205. self.filteredList = entryContent
  206. self.resultListModelList = QStringListModel(self.filteredList)
  207. self.context.setContextProperty("resultListModel", self.resultListModelList)
  208. self.resultListModelMaxIndex = len(self.filteredList) - 1
  209. self.context.setContextProperty("resultListModelMaxIndex", self.resultListModelMaxIndex)
  210. self.context.setContextProperty("resultListModelMakeItalic", False)
  211. QQmlProperty.write(self.resultListModel, "currentIndex", 0)
  212. QQmlProperty.write(self.searchInputModel, "text", "")
  213. def selectField(self):
  214. if len(self.filteredList) == 0:
  215. return
  216. currentIndex = QQmlProperty.read(self.resultListModel, "currentIndex")
  217. if self.filteredList[currentIndex] == "********":
  218. self.store.copyEntryToClipboard(self.chosenEntry)
  219. self.window.close()
  220. return
  221. # Only copy the final part. For example, if the entry is named
  222. # "URL: https://example.org/", only copy "https://example.org/" to the
  223. # clipboard
  224. copyStringParts = self.filteredList[currentIndex].split(": ", 1)
  225. copyString = copyStringParts[1] if len(copyStringParts) > 1 else copyStringParts[0]
  226. # Use the same clipboard that password store is set to use (untested)
  227. selection = os.getenv("PASSWORD_STORE_X_SELECTION", "clipboard")
  228. proc = Popen(["xclip", "-selection", selection], stdin=PIPE)
  229. proc.communicate(copyString.encode("ascii"))
  230. self.window.close()
  231. return
  232. class InputDialog(QDialog):
  233. def __init__(self, question, text, parent=None):
  234. super().__init__(parent)
  235. self.setWindowTitle("PyPass")
  236. layout = QVBoxLayout(self)
  237. layout.addWidget(QLabel(question))
  238. self.textEdit = QTextEdit(self)
  239. self.textEdit.setPlainText(text)
  240. layout.addWidget(self.textEdit)
  241. button = QDialogButtonBox(QDialogButtonBox.Ok)
  242. button.accepted.connect(self.accept)
  243. layout.addWidget(button)
  244. def show(self):
  245. result = self.exec_()
  246. return (self.textEdit.toPlainText(), result == QDialog.Accepted)
  247. class Window(QDialog):
  248. def __init__(self, vm, settings, parent=None):
  249. super().__init__(parent)
  250. self.engine = QQmlApplicationEngine(self)
  251. self.vm = vm
  252. context = self.engine.rootContext()
  253. context.setContextProperty("resultListModel", self.vm.resultListModelList)
  254. context.setContextProperty("resultListModelMaxIndex", self.vm.resultListModelMaxIndex)
  255. context.setContextProperty("resultListModelMakeItalic", True)
  256. context.setContextProperty("messageListModelList", self.vm.messageListModelList)
  257. self.engine.load(QUrl.fromLocalFile(os.path.dirname(os.path.realpath(__file__)) + "/main.qml"))
  258. self.window = self.engine.rootObjects()[0]
  259. escapeShortcut = self.window.findChild(QObject, "escapeShortcut")
  260. tabShortcut = self.window.findChild(QObject, "tabShortcut")
  261. searchInputModel = self.window.findChild(QObject, "searchInputModel")
  262. resultListModel = self.window.findChild(QObject, "resultListModel")
  263. clearOldMessagesTimer = self.window.findChild(QObject, "clearOldMessagesTimer")
  264. self.vm.bindContext(context, self, searchInputModel, resultListModel)
  265. escapeShortcut.activated.connect(self.vm.goUp)
  266. tabShortcut.activated.connect(self.vm.tabComplete)
  267. searchInputModel.textChanged.connect(self.vm.search)
  268. searchInputModel.accepted.connect(self.vm.select)
  269. clearOldMessagesTimer.triggered.connect(self.vm.clearOldMessages)
  270. def show(self):
  271. self.window.show()
  272. self.activateWindow()
  273. def close(self):
  274. if not settings['closeWhenDone']:
  275. self.window.hide()
  276. QQmlProperty.write(self.vm.searchInputModel, "text", "")
  277. self.vm.chosenEntry = None
  278. self.vm.search()
  279. else:
  280. sys.exit(0)
  281. def loadSettings(argv):
  282. # Default options
  283. settings = {'binary': None, 'closeWhenDone': False, 'store': 'pass'}
  284. try:
  285. opts, args = getopt.getopt(argv, "hb:s:", ["help", "binary=", "close-when-done", "store="])
  286. except getopt.GetoptError as err:
  287. print(err)
  288. print("")
  289. usage()
  290. sys.exit(1)
  291. for opt, args in opts:
  292. if opt in ("-h", "--help"):
  293. usage()
  294. sys.exit()
  295. elif opt == "--close-when-done":
  296. settings['closeWhenDone'] = True
  297. elif opt in ("-s", "--store"):
  298. settings['store'] = args
  299. elif opt in ("-b", "--binary"):
  300. settings['binary'] = args
  301. return settings
  302. def usage():
  303. print("Options:")
  304. print("")
  305. print("--binary : choose the name of the binary to use. Defaults")
  306. print(" to 'pass' for the pass store and todo.sh for")
  307. print(" the todo.sh store. Paths are allowed")
  308. print("")
  309. print("--close-when-done : close after completing an action such as copying")
  310. print(" a password or closing the application (through")
  311. print(" escape or (on most systems) Alt+F4) instead of")
  312. print(" staying in memory. This also allows multiple")
  313. print(" instances to be ran at once.")
  314. print("")
  315. print("--store : use another store than pass. Currently supported")
  316. print(" are pass and todo.sh.")
  317. def initPersist(store):
  318. # Ensure only one PyPass instance is running. If one already exists,
  319. # signal it to open the password selection window.
  320. # This way, we can keep the password list in memory and start up extra
  321. # quickly.
  322. pidfile = "/tmp/pypass-" + store + ".pid"
  323. if os.path.isfile(pidfile):
  324. # Notify the main process
  325. try:
  326. os.kill(int(open(pidfile, 'r').read()), signal.SIGUSR1)
  327. sys.exit()
  328. except ProcessLookupError:
  329. # PyPass closed, but died not clean up its pidfile
  330. pass
  331. # We are the only instance, claim our pidfile
  332. pid = str(os.getpid())
  333. open(pidfile, 'w').write(pid)
  334. def mainLoop(app, q, vm, window):
  335. while True:
  336. try:
  337. q.get_nowait()
  338. except Empty:
  339. app.processEvents()
  340. time.sleep(0.01)
  341. continue
  342. vm.search()
  343. window.update()
  344. q.task_done()
  345. if __name__ == "__main__":
  346. settings = loadSettings(sys.argv[1:])
  347. try:
  348. storeImport = __import__('store_' + settings['store'].replace('.', '_'), fromlist=['Store'])
  349. except ImportError:
  350. print('Unsupported store requested.')
  351. sys.exit(2)
  352. Store = getattr(storeImport, 'Store')
  353. if not settings['closeWhenDone']:
  354. initPersist(settings['store'])
  355. # Set up a queue so that the store can communicate with the main thread
  356. q = Queue()
  357. app = QApplication(sys.argv)
  358. # Set up the window
  359. viewModel = ViewModel()
  360. window = Window(viewModel, settings)
  361. store = Store(settings['binary'], viewModel, window, q)
  362. viewModel.bindStore(store)
  363. # Handle signal
  364. signalHandler = SignalHandler(window)
  365. signal.signal(signal.SIGUSR1, signalHandler.handle)
  366. # Run until the app quits, then clean up
  367. window.show()
  368. mainLoop(app, q, viewModel, window)
  369. sys.exit(app.exec_())
  370. store.stop()
  371. if not settings['closeWhenDone']:
  372. os.unlink(pidfile)