text.py (19938B)
1 import tty 2 import sys 3 import curses 4 import datetime 5 import locale 6 from decimal import Decimal 7 import getpass 8 import logging 9 from typing import TYPE_CHECKING 10 11 import electrum 12 from electrum import util 13 from electrum.util import format_satoshis 14 from electrum.bitcoin import is_address, COIN 15 from electrum.transaction import PartialTxOutput 16 from electrum.wallet import Wallet 17 from electrum.wallet_db import WalletDB 18 from electrum.storage import WalletStorage 19 from electrum.network import NetworkParameters, TxBroadcastError, BestEffortRequestFailed 20 from electrum.interface import ServerAddr 21 from electrum.logging import console_stderr_handler 22 23 if TYPE_CHECKING: 24 from electrum.daemon import Daemon 25 from electrum.simple_config import SimpleConfig 26 from electrum.plugin import Plugins 27 28 29 _ = lambda x:x # i18n 30 31 32 class ElectrumGui: 33 34 def __init__(self, config: 'SimpleConfig', daemon: 'Daemon', plugins: 'Plugins'): 35 36 self.config = config 37 self.network = daemon.network 38 storage = WalletStorage(config.get_wallet_path()) 39 if not storage.file_exists(): 40 print("Wallet not found. try 'electrum create'") 41 exit() 42 if storage.is_encrypted(): 43 password = getpass.getpass('Password:', stream=None) 44 storage.decrypt(password) 45 db = WalletDB(storage.read(), manual_upgrades=False) 46 self.wallet = Wallet(db, storage, config=config) 47 self.wallet.start_network(self.network) 48 self.contacts = self.wallet.contacts 49 50 locale.setlocale(locale.LC_ALL, '') 51 self.encoding = locale.getpreferredencoding() 52 53 self.stdscr = curses.initscr() 54 curses.noecho() 55 curses.cbreak() 56 curses.start_color() 57 curses.use_default_colors() 58 curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE) 59 curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_CYAN) 60 curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE) 61 self.stdscr.keypad(1) 62 self.stdscr.border(0) 63 self.maxy, self.maxx = self.stdscr.getmaxyx() 64 self.set_cursor(0) 65 self.w = curses.newwin(10, 50, 5, 5) 66 67 console_stderr_handler.setLevel(logging.CRITICAL) 68 self.tab = 0 69 self.pos = 0 70 self.popup_pos = 0 71 72 self.str_recipient = "" 73 self.str_description = "" 74 self.str_amount = "" 75 self.str_fee = "" 76 self.history = None 77 78 util.register_callback(self.update, ['wallet_updated', 'network_updated']) 79 80 self.tab_names = [_("History"), _("Send"), _("Receive"), _("Addresses"), _("Contacts"), _("Banner")] 81 self.num_tabs = len(self.tab_names) 82 83 84 def set_cursor(self, x): 85 try: 86 curses.curs_set(x) 87 except Exception: 88 pass 89 90 def restore_or_create(self): 91 pass 92 93 def verify_seed(self): 94 pass 95 96 def get_string(self, y, x): 97 self.set_cursor(1) 98 curses.echo() 99 self.stdscr.addstr( y, x, " "*20, curses.A_REVERSE) 100 s = self.stdscr.getstr(y,x) 101 curses.noecho() 102 self.set_cursor(0) 103 return s 104 105 def update(self, event, *args): 106 self.update_history() 107 if self.tab == 0: 108 self.print_history() 109 self.refresh() 110 111 def print_history(self): 112 113 width = [20, 40, 14, 14] 114 delta = (self.maxx - sum(width) - 4)/3 115 format_str = "%"+"%d"%width[0]+"s"+"%"+"%d"%(width[1]+delta)+"s"+"%"+"%d"%(width[2]+delta)+"s"+"%"+"%d"%(width[3]+delta)+"s" 116 117 if self.history is None: 118 self.update_history() 119 120 self.print_list(self.history[::-1], format_str%( _("Date"), _("Description"), _("Amount"), _("Balance"))) 121 122 def update_history(self): 123 width = [20, 40, 14, 14] 124 delta = (self.maxx - sum(width) - 4)/3 125 format_str = "%"+"%d"%width[0]+"s"+"%"+"%d"%(width[1]+delta)+"s"+"%"+"%d"%(width[2]+delta)+"s"+"%"+"%d"%(width[3]+delta)+"s" 126 127 b = 0 128 self.history = [] 129 for hist_item in self.wallet.get_history(): 130 if hist_item.tx_mined_status.conf: 131 timestamp = hist_item.tx_mined_status.timestamp 132 try: 133 time_str = datetime.datetime.fromtimestamp(timestamp).isoformat(' ')[:-3] 134 except Exception: 135 time_str = "------" 136 else: 137 time_str = 'unconfirmed' 138 139 label = self.wallet.get_label_for_txid(hist_item.txid) 140 if len(label) > 40: 141 label = label[0:37] + '...' 142 self.history.append(format_str % (time_str, label, format_satoshis(hist_item.delta, whitespaces=True), 143 format_satoshis(hist_item.balance, whitespaces=True))) 144 145 146 def print_balance(self): 147 if not self.network: 148 msg = _("Offline") 149 elif self.network.is_connected(): 150 if not self.wallet.up_to_date: 151 msg = _("Synchronizing...") 152 else: 153 c, u, x = self.wallet.get_balance() 154 msg = _("Balance")+": %f "%(Decimal(c) / COIN) 155 if u: 156 msg += " [%f unconfirmed]"%(Decimal(u) / COIN) 157 if x: 158 msg += " [%f unmatured]"%(Decimal(x) / COIN) 159 else: 160 msg = _("Not connected") 161 162 self.stdscr.addstr( self.maxy -1, 3, msg) 163 164 for i in range(self.num_tabs): 165 self.stdscr.addstr( 0, 2 + 2*i + len(''.join(self.tab_names[0:i])), ' '+self.tab_names[i]+' ', curses.A_BOLD if self.tab == i else 0) 166 167 self.stdscr.addstr(self.maxy -1, self.maxx-30, ' '.join([_("Settings"), _("Network"), _("Quit")])) 168 169 def print_receive(self): 170 addr = self.wallet.get_receiving_address() 171 self.stdscr.addstr(2, 1, "Address: "+addr) 172 self.print_qr(addr) 173 174 def print_contacts(self): 175 messages = map(lambda x: "%20s %45s "%(x[0], x[1][1]), self.contacts.items()) 176 self.print_list(messages, "%19s %15s "%("Key", "Value")) 177 178 def print_addresses(self): 179 fmt = "%-35s %-30s" 180 messages = map(lambda addr: fmt % (addr, self.wallet.get_label(addr)), self.wallet.get_addresses()) 181 self.print_list(messages, fmt % ("Address", "Label")) 182 183 def print_edit_line(self, y, label, text, index, size): 184 text += " "*(size - len(text) ) 185 self.stdscr.addstr( y, 2, label) 186 self.stdscr.addstr( y, 15, text, curses.A_REVERSE if self.pos%6==index else curses.color_pair(1)) 187 188 def print_send_tab(self): 189 self.stdscr.clear() 190 self.print_edit_line(3, _("Pay to"), self.str_recipient, 0, 40) 191 self.print_edit_line(5, _("Description"), self.str_description, 1, 40) 192 self.print_edit_line(7, _("Amount"), self.str_amount, 2, 15) 193 self.print_edit_line(9, _("Fee"), self.str_fee, 3, 15) 194 self.stdscr.addstr( 12, 15, _("[Send]"), curses.A_REVERSE if self.pos%6==4 else curses.color_pair(2)) 195 self.stdscr.addstr( 12, 25, _("[Clear]"), curses.A_REVERSE if self.pos%6==5 else curses.color_pair(2)) 196 self.maxpos = 6 197 198 def print_banner(self): 199 if self.network and self.network.banner: 200 banner = self.network.banner 201 banner = banner.replace('\r', '') 202 self.print_list(banner.split('\n')) 203 204 def print_qr(self, data): 205 import qrcode 206 try: 207 from StringIO import StringIO 208 except ImportError: 209 from io import StringIO 210 211 s = StringIO() 212 self.qr = qrcode.QRCode() 213 self.qr.add_data(data) 214 self.qr.print_ascii(out=s, invert=False) 215 msg = s.getvalue() 216 lines = msg.split('\n') 217 try: 218 for i, l in enumerate(lines): 219 l = l.encode("utf-8") 220 self.stdscr.addstr(i+5, 5, l, curses.color_pair(3)) 221 except curses.error: 222 m = 'error. screen too small?' 223 m = m.encode(self.encoding) 224 self.stdscr.addstr(5, 1, m, 0) 225 226 227 def print_list(self, lst, firstline = None): 228 lst = list(lst) 229 self.maxpos = len(lst) 230 if not self.maxpos: return 231 if firstline: 232 firstline += " "*(self.maxx -2 - len(firstline)) 233 self.stdscr.addstr( 1, 1, firstline ) 234 for i in range(self.maxy-4): 235 msg = lst[i] if i < len(lst) else "" 236 msg += " "*(self.maxx - 2 - len(msg)) 237 m = msg[0:self.maxx - 2] 238 m = m.encode(self.encoding) 239 self.stdscr.addstr( i+2, 1, m, curses.A_REVERSE if i == (self.pos % self.maxpos) else 0) 240 241 def refresh(self): 242 if self.tab == -1: return 243 self.stdscr.border(0) 244 self.print_balance() 245 self.stdscr.refresh() 246 247 def main_command(self): 248 c = self.stdscr.getch() 249 print(c) 250 cc = curses.unctrl(c).decode() 251 if c == curses.KEY_RIGHT: self.tab = (self.tab + 1)%self.num_tabs 252 elif c == curses.KEY_LEFT: self.tab = (self.tab - 1)%self.num_tabs 253 elif c == curses.KEY_DOWN: self.pos +=1 254 elif c == curses.KEY_UP: self.pos -= 1 255 elif c == 9: self.pos +=1 # tab 256 elif cc in ['^W', '^C', '^X', '^Q']: self.tab = -1 257 elif cc in ['^N']: self.network_dialog() 258 elif cc == '^S': self.settings_dialog() 259 else: return c 260 if self.pos<0: self.pos=0 261 if self.pos>=self.maxpos: self.pos=self.maxpos - 1 262 263 def run_tab(self, i, print_func, exec_func): 264 while self.tab == i: 265 self.stdscr.clear() 266 print_func() 267 self.refresh() 268 c = self.main_command() 269 if c: exec_func(c) 270 271 272 def run_history_tab(self, c): 273 if c == 10: 274 out = self.run_popup('',["blah","foo"]) 275 276 277 def edit_str(self, target, c, is_num=False): 278 # detect backspace 279 cc = curses.unctrl(c).decode() 280 if c in [8, 127, 263] and target: 281 target = target[:-1] 282 elif not is_num or cc in '0123456789.': 283 target += cc 284 return target 285 286 287 def run_send_tab(self, c): 288 if self.pos%6 == 0: 289 self.str_recipient = self.edit_str(self.str_recipient, c) 290 if self.pos%6 == 1: 291 self.str_description = self.edit_str(self.str_description, c) 292 if self.pos%6 == 2: 293 self.str_amount = self.edit_str(self.str_amount, c, True) 294 elif self.pos%6 == 3: 295 self.str_fee = self.edit_str(self.str_fee, c, True) 296 elif self.pos%6==4: 297 if c == 10: self.do_send() 298 elif self.pos%6==5: 299 if c == 10: self.do_clear() 300 301 302 def run_receive_tab(self, c): 303 if c == 10: 304 out = self.run_popup('Address', ["Edit label", "Freeze", "Prioritize"]) 305 306 def run_contacts_tab(self, c): 307 if c == 10 and self.contacts: 308 out = self.run_popup('Address', ["Copy", "Pay to", "Edit label", "Delete"]).get('button') 309 key = list(self.contacts.keys())[self.pos%len(self.contacts.keys())] 310 if out == "Pay to": 311 self.tab = 1 312 self.str_recipient = key 313 self.pos = 2 314 elif out == "Edit label": 315 s = self.get_string(6 + self.pos, 18) 316 if s: 317 self.wallet.set_label(key, s) 318 319 def run_banner_tab(self, c): 320 self.show_message(repr(c)) 321 pass 322 323 def main(self): 324 325 tty.setraw(sys.stdin) 326 try: 327 while self.tab != -1: 328 self.run_tab(0, self.print_history, self.run_history_tab) 329 self.run_tab(1, self.print_send_tab, self.run_send_tab) 330 self.run_tab(2, self.print_receive, self.run_receive_tab) 331 self.run_tab(3, self.print_addresses, self.run_banner_tab) 332 self.run_tab(4, self.print_contacts, self.run_contacts_tab) 333 self.run_tab(5, self.print_banner, self.run_banner_tab) 334 except curses.error as e: 335 raise Exception("Error with curses. Is your screen too small?") from e 336 finally: 337 tty.setcbreak(sys.stdin) 338 curses.nocbreak() 339 self.stdscr.keypad(0) 340 curses.echo() 341 curses.endwin() 342 343 def stop(self): 344 pass 345 346 def do_clear(self): 347 self.str_amount = '' 348 self.str_recipient = '' 349 self.str_fee = '' 350 self.str_description = '' 351 352 def do_send(self): 353 if not is_address(self.str_recipient): 354 self.show_message(_('Invalid Bitcoin address')) 355 return 356 try: 357 amount = int(Decimal(self.str_amount) * COIN) 358 except Exception: 359 self.show_message(_('Invalid Amount')) 360 return 361 try: 362 fee = int(Decimal(self.str_fee) * COIN) 363 except Exception: 364 self.show_message(_('Invalid Fee')) 365 return 366 367 if self.wallet.has_password(): 368 password = self.password_dialog() 369 if not password: 370 return 371 else: 372 password = None 373 try: 374 tx = self.wallet.mktx(outputs=[PartialTxOutput.from_address_and_value(self.str_recipient, amount)], 375 password=password, 376 fee=fee) 377 except Exception as e: 378 self.show_message(repr(e)) 379 return 380 381 if self.str_description: 382 self.wallet.set_label(tx.txid(), self.str_description) 383 384 self.show_message(_("Please wait..."), getchar=False) 385 try: 386 self.network.run_from_another_thread(self.network.broadcast_transaction(tx)) 387 except TxBroadcastError as e: 388 msg = e.get_message_for_gui() 389 self.show_message(msg) 390 except BestEffortRequestFailed as e: 391 msg = repr(e) 392 self.show_message(msg) 393 else: 394 self.show_message(_('Payment sent.')) 395 self.do_clear() 396 #self.update_contacts_tab() 397 398 def show_message(self, message, getchar = True): 399 w = self.w 400 w.clear() 401 w.border(0) 402 for i, line in enumerate(message.split('\n')): 403 w.addstr(2+i,2,line) 404 w.refresh() 405 if getchar: c = self.stdscr.getch() 406 407 def run_popup(self, title, items): 408 return self.run_dialog(title, list(map(lambda x: {'type':'button','label':x}, items)), interval=1, y_pos = self.pos+3) 409 410 def network_dialog(self): 411 if not self.network: 412 return 413 net_params = self.network.get_parameters() 414 server_addr = net_params.server 415 proxy_config, auto_connect = net_params.proxy, net_params.auto_connect 416 srv = 'auto-connect' if auto_connect else str(self.network.default_server) 417 out = self.run_dialog('Network', [ 418 {'label':'server', 'type':'str', 'value':srv}, 419 {'label':'proxy', 'type':'str', 'value':self.config.get('proxy', '')}, 420 ], buttons = 1) 421 if out: 422 if out.get('server'): 423 server_str = out.get('server') 424 auto_connect = server_str == 'auto-connect' 425 if not auto_connect: 426 try: 427 server_addr = ServerAddr.from_str(server_str) 428 except Exception: 429 self.show_message("Error:" + server_str + "\nIn doubt, type \"auto-connect\"") 430 return False 431 if out.get('server') or out.get('proxy'): 432 proxy = electrum.network.deserialize_proxy(out.get('proxy')) if out.get('proxy') else proxy_config 433 net_params = NetworkParameters(server=server_addr, 434 proxy=proxy, 435 auto_connect=auto_connect) 436 self.network.run_from_another_thread(self.network.set_parameters(net_params)) 437 438 def settings_dialog(self): 439 fee = str(Decimal(self.config.fee_per_kb()) / COIN) 440 out = self.run_dialog('Settings', [ 441 {'label':'Default fee', 'type':'satoshis', 'value': fee } 442 ], buttons = 1) 443 if out: 444 if out.get('Default fee'): 445 fee = int(Decimal(out['Default fee']) * COIN) 446 self.config.set_key('fee_per_kb', fee, True) 447 448 449 def password_dialog(self): 450 out = self.run_dialog('Password', [ 451 {'label':'Password', 'type':'password', 'value':''} 452 ], buttons = 1) 453 return out.get('Password') 454 455 456 def run_dialog(self, title, items, interval=2, buttons=None, y_pos=3): 457 self.popup_pos = 0 458 459 self.w = curses.newwin( 5 + len(list(items))*interval + (2 if buttons else 0), 50, y_pos, 5) 460 w = self.w 461 out = {} 462 while True: 463 w.clear() 464 w.border(0) 465 w.addstr( 0, 2, title) 466 467 num = len(list(items)) 468 469 numpos = num 470 if buttons: numpos += 2 471 472 for i in range(num): 473 item = items[i] 474 label = item.get('label') 475 if item.get('type') == 'list': 476 value = item.get('value','') 477 elif item.get('type') == 'satoshis': 478 value = item.get('value','') 479 elif item.get('type') == 'str': 480 value = item.get('value','') 481 elif item.get('type') == 'password': 482 value = '*'*len(item.get('value','')) 483 else: 484 value = '' 485 if value is None: 486 value = '' 487 if len(value)<20: 488 value += ' '*(20-len(value)) 489 490 if 'value' in item: 491 w.addstr( 2+interval*i, 2, label) 492 w.addstr( 2+interval*i, 15, value, curses.A_REVERSE if self.popup_pos%numpos==i else curses.color_pair(1) ) 493 else: 494 w.addstr( 2+interval*i, 2, label, curses.A_REVERSE if self.popup_pos%numpos==i else 0) 495 496 if buttons: 497 w.addstr( 5+interval*i, 10, "[ ok ]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-2) else curses.color_pair(2)) 498 w.addstr( 5+interval*i, 25, "[cancel]", curses.A_REVERSE if self.popup_pos%numpos==(numpos-1) else curses.color_pair(2)) 499 500 w.refresh() 501 502 c = self.stdscr.getch() 503 if c in [ord('q'), 27]: break 504 elif c in [curses.KEY_LEFT, curses.KEY_UP]: self.popup_pos -= 1 505 elif c in [curses.KEY_RIGHT, curses.KEY_DOWN]: self.popup_pos +=1 506 else: 507 i = self.popup_pos%numpos 508 if buttons and c==10: 509 if i == numpos-2: 510 return out 511 elif i == numpos -1: 512 return {} 513 514 item = items[i] 515 _type = item.get('type') 516 517 if _type == 'str': 518 item['value'] = self.edit_str(item['value'], c) 519 out[item.get('label')] = item.get('value') 520 521 elif _type == 'password': 522 item['value'] = self.edit_str(item['value'], c) 523 out[item.get('label')] = item ['value'] 524 525 elif _type == 'satoshis': 526 item['value'] = self.edit_str(item['value'], c, True) 527 out[item.get('label')] = item.get('value') 528 529 elif _type == 'list': 530 choices = item.get('choices') 531 try: 532 j = choices.index(item.get('value')) 533 except Exception: 534 j = 0 535 new_choice = choices[(j + 1)% len(choices)] 536 item['value'] = new_choice 537 out[item.get('label')] = item.get('value') 538 539 elif _type == 'button': 540 out['button'] = item.get('label') 541 break 542 543 return out