onionshare/onionshare_gui/tab/mode/history.py

805 lines
27 KiB
Python

# -*- coding: utf-8 -*-
"""
OnionShare | https://onionshare.org/
Copyright (C) 2014-2018 Micah Lee <micah@micahflee.com>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
import time
import subprocess
import os
from datetime import datetime
from PyQt5 import QtCore, QtWidgets, QtGui
from onionshare import strings
from ...widgets import Alert
class HistoryItem(QtWidgets.QWidget):
"""
The base history item
"""
STATUS_STARTED = 0
STATUS_FINISHED = 1
STATUS_CANCELED = 2
def __init__(self):
super(HistoryItem, self).__init__()
def update(self):
pass
def cancel(self):
pass
def get_finished_label_text(self, started):
"""
When an item finishes, returns a string displaying the start/end datetime range.
started is a datetime object.
"""
return self._get_label_text(
"gui_all_modes_transfer_finished",
"gui_all_modes_transfer_finished_range",
started,
)
def get_canceled_label_text(self, started):
"""
When an item is canceled, returns a string displaying the start/end datetime range.
started is a datetime object.
"""
return self._get_label_text(
"gui_all_modes_transfer_canceled",
"gui_all_modes_transfer_canceled_range",
started,
)
def _get_label_text(self, string_name, string_range_name, started):
"""
Return a string that contains a date, or date range.
"""
ended = datetime.now()
if (
started.year == ended.year
and started.month == ended.month
and started.day == ended.day
):
if started.hour == ended.hour and started.minute == ended.minute:
text = strings._(string_name).format(started.strftime("%b %d, %I:%M%p"))
else:
text = strings._(string_range_name).format(
started.strftime("%b %d, %I:%M%p"), ended.strftime("%I:%M%p")
)
else:
text = strings._(string_range_name).format(
started.strftime("%b %d, %I:%M%p"), ended.strftime("%b %d, %I:%M%p")
)
return text
class ShareHistoryItem(HistoryItem):
"""
Download history item, for share mode
"""
def __init__(self, common, id, total_bytes):
super(ShareHistoryItem, self).__init__()
self.common = common
self.id = id
self.total_bytes = total_bytes
self.downloaded_bytes = 0
self.started = time.time()
self.started_dt = datetime.fromtimestamp(self.started)
self.status = HistoryItem.STATUS_STARTED
# Label
self.label = QtWidgets.QLabel(
strings._("gui_all_modes_transfer_started").format(
self.started_dt.strftime("%b %d, %I:%M%p")
)
)
# Progress bar
self.progress_bar = QtWidgets.QProgressBar()
self.progress_bar.setTextVisible(True)
self.progress_bar.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.progress_bar.setAlignment(QtCore.Qt.AlignHCenter)
self.progress_bar.setMinimum(0)
self.progress_bar.setMaximum(total_bytes)
self.progress_bar.setValue(0)
self.progress_bar.setStyleSheet(
self.common.gui.css["downloads_uploads_progress_bar"]
)
self.progress_bar.total_bytes = total_bytes
# Layout
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(self.progress_bar)
self.setLayout(layout)
# Start at 0
self.update(0)
def update(self, downloaded_bytes):
self.downloaded_bytes = downloaded_bytes
self.progress_bar.setValue(downloaded_bytes)
if downloaded_bytes == self.progress_bar.total_bytes:
pb_fmt = strings._("gui_all_modes_progress_complete").format(
self.common.format_seconds(time.time() - self.started)
)
# Change the label
self.label.setText(self.get_finished_label_text(self.started_dt))
self.status = HistoryItem.STATUS_FINISHED
else:
elapsed = time.time() - self.started
if elapsed < 10:
# Wait a couple of seconds for the download rate to stabilize.
# This prevents a "Windows copy dialog"-esque experience at
# the beginning of the download.
pb_fmt = strings._("gui_all_modes_progress_starting").format(
self.common.human_readable_filesize(downloaded_bytes)
)
else:
pb_fmt = strings._("gui_all_modes_progress_eta").format(
self.common.human_readable_filesize(downloaded_bytes),
self.estimated_time_remaining,
)
self.progress_bar.setFormat(pb_fmt)
def cancel(self):
self.progress_bar.setFormat(strings._("gui_canceled"))
self.status = HistoryItem.STATUS_CANCELED
@property
def estimated_time_remaining(self):
return self.common.estimated_time_remaining(
self.downloaded_bytes, self.total_bytes, self.started
)
class ReceiveHistoryItemFile(QtWidgets.QWidget):
def __init__(self, common, filename):
super(ReceiveHistoryItemFile, self).__init__()
self.common = common
self.common.log("ReceiveHistoryItemFile", "__init__", f"filename: {filename}")
self.filename = filename
self.dir = None
self.started = datetime.now()
# Filename label
self.filename_label = QtWidgets.QLabel(self.filename)
self.filename_label_width = self.filename_label.width()
# File size label
self.filesize_label = QtWidgets.QLabel()
self.filesize_label.setStyleSheet(self.common.gui.css["receive_file_size"])
self.filesize_label.hide()
# Folder button
folder_pixmap = QtGui.QPixmap.fromImage(
QtGui.QImage(self.common.get_resource_path("images/open_folder.png"))
)
folder_icon = QtGui.QIcon(folder_pixmap)
self.folder_button = QtWidgets.QPushButton()
self.folder_button.clicked.connect(self.open_folder)
self.folder_button.setIcon(folder_icon)
self.folder_button.setIconSize(folder_pixmap.rect().size())
self.folder_button.setFlat(True)
self.folder_button.hide()
# Layouts
layout = QtWidgets.QHBoxLayout()
layout.addWidget(self.filename_label)
layout.addWidget(self.filesize_label)
layout.addStretch()
layout.addWidget(self.folder_button)
self.setLayout(layout)
def update(self, uploaded_bytes, complete):
self.filesize_label.setText(self.common.human_readable_filesize(uploaded_bytes))
self.filesize_label.show()
if complete:
self.folder_button.show()
def rename(self, new_filename):
self.filename = new_filename
self.filename_label.setText(self.filename)
def set_dir(self, dir):
self.dir = dir
def open_folder(self):
"""
Open the downloads folder, with the file selected, in a cross-platform manner
"""
self.common.log("ReceiveHistoryItemFile", "open_folder")
if not self.dir:
self.common.log(
"ReceiveHistoryItemFile",
"open_folder",
"dir has not been set yet, can't open folder",
)
return
abs_filename = os.path.join(self.dir, self.filename)
# Linux
if self.common.platform == "Linux" or self.common.platform == "BSD":
try:
# If nautilus is available, open it
subprocess.Popen(["nautilus", abs_filename])
except:
Alert(
self.common,
strings._("gui_open_folder_error_nautilus").format(abs_filename),
)
# macOS
elif self.common.platform == "Darwin":
subprocess.call(["open", "-R", abs_filename])
# Windows
elif self.common.platform == "Windows":
subprocess.Popen(["explorer", f"/select,{abs_filename}"])
class ReceiveHistoryItem(HistoryItem):
def __init__(self, common, id, content_length):
super(ReceiveHistoryItem, self).__init__()
self.common = common
self.id = id
self.content_length = content_length
self.started = datetime.now()
self.status = HistoryItem.STATUS_STARTED
# Label
self.label = QtWidgets.QLabel(
strings._("gui_all_modes_transfer_started").format(
self.started.strftime("%b %d, %I:%M%p")
)
)
# Progress bar
self.progress_bar = QtWidgets.QProgressBar()
self.progress_bar.setTextVisible(True)
self.progress_bar.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.progress_bar.setAlignment(QtCore.Qt.AlignHCenter)
self.progress_bar.setMinimum(0)
self.progress_bar.setValue(0)
self.progress_bar.setStyleSheet(
self.common.gui.css["downloads_uploads_progress_bar"]
)
# This layout contains file widgets
self.files_layout = QtWidgets.QVBoxLayout()
self.files_layout.setContentsMargins(0, 0, 0, 0)
files_widget = QtWidgets.QWidget()
files_widget.setStyleSheet(self.common.gui.css["receive_file"])
files_widget.setLayout(self.files_layout)
# Layout
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(self.progress_bar)
layout.addWidget(files_widget)
layout.addStretch()
self.setLayout(layout)
# We're also making a dictionary of file widgets, to make them easier to access
self.files = {}
def update(self, data):
"""
Using the progress from Web, update the progress bar and file size labels
for each file
"""
if data["action"] == "progress":
total_uploaded_bytes = 0
for filename in data["progress"]:
total_uploaded_bytes += data["progress"][filename]["uploaded_bytes"]
# Update the progress bar
self.progress_bar.setMaximum(self.content_length)
self.progress_bar.setValue(total_uploaded_bytes)
elapsed = datetime.now() - self.started
if elapsed.seconds < 10:
pb_fmt = strings._("gui_all_modes_progress_starting").format(
self.common.human_readable_filesize(total_uploaded_bytes)
)
else:
estimated_time_remaining = self.common.estimated_time_remaining(
total_uploaded_bytes, self.content_length, self.started.timestamp()
)
pb_fmt = strings._("gui_all_modes_progress_eta").format(
self.common.human_readable_filesize(total_uploaded_bytes),
estimated_time_remaining,
)
# Using list(progress) to avoid "RuntimeError: dictionary changed size during iteration"
for filename in list(data["progress"]):
# Add a new file if needed
if filename not in self.files:
self.files[filename] = ReceiveHistoryItemFile(self.common, filename)
self.files_layout.addWidget(self.files[filename])
# Update the file
self.files[filename].update(
data["progress"][filename]["uploaded_bytes"],
data["progress"][filename]["complete"],
)
elif data["action"] == "rename":
self.files[data["old_filename"]].rename(data["new_filename"])
self.files[data["new_filename"]] = self.files.pop(data["old_filename"])
elif data["action"] == "set_dir":
self.files[data["filename"]].set_dir(data["dir"])
elif data["action"] == "finished":
# Change the status
self.status = HistoryItem.STATUS_FINISHED
# Hide the progress bar
self.progress_bar.hide()
# Change the label
self.label.setText(self.get_finished_label_text(self.started))
elif data["action"] == "canceled":
# Change the status
self.status = HistoryItem.STATUS_CANCELED
# Hide the progress bar
self.progress_bar.hide()
# Change the label
self.label.setText(self.get_canceled_label_text(self.started))
class IndividualFileHistoryItem(HistoryItem):
"""
Individual file history item, for share mode viewing of individual files
"""
def __init__(self, common, data, path):
super(IndividualFileHistoryItem, self).__init__()
self.status = HistoryItem.STATUS_STARTED
self.common = common
self.id = id
self.path = path
self.total_bytes = 0
self.downloaded_bytes = 0
self.started = time.time()
self.started_dt = datetime.fromtimestamp(self.started)
self.status = HistoryItem.STATUS_STARTED
self.directory_listing = "directory_listing" in data
# Labels
self.timestamp_label = QtWidgets.QLabel(
self.started_dt.strftime("%b %d, %I:%M%p")
)
self.timestamp_label.setStyleSheet(
self.common.gui.css["history_individual_file_timestamp_label"]
)
self.path_label = QtWidgets.QLabel(self.path)
self.status_code_label = QtWidgets.QLabel()
# Progress bar
self.progress_bar = QtWidgets.QProgressBar()
self.progress_bar.setTextVisible(True)
self.progress_bar.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.progress_bar.setAlignment(QtCore.Qt.AlignHCenter)
self.progress_bar.setValue(0)
self.progress_bar.setStyleSheet(
self.common.gui.css["downloads_uploads_progress_bar"]
)
# Text layout
labels_layout = QtWidgets.QHBoxLayout()
labels_layout.addWidget(self.timestamp_label)
labels_layout.addWidget(self.path_label)
labels_layout.addWidget(self.status_code_label)
labels_layout.addStretch()
# Layout
layout = QtWidgets.QVBoxLayout()
layout.addLayout(labels_layout)
layout.addWidget(self.progress_bar)
self.setLayout(layout)
# Is a status code already sent?
if "status_code" in data:
self.status_code_label.setText(str(data["status_code"]))
if data["status_code"] >= 200 and data["status_code"] < 300:
self.status_code_label.setStyleSheet(
self.common.gui.css["history_individual_file_status_code_label_2xx"]
)
if data["status_code"] >= 400 and data["status_code"] < 500:
self.status_code_label.setStyleSheet(
self.common.gui.css["history_individual_file_status_code_label_4xx"]
)
self.status = HistoryItem.STATUS_FINISHED
self.progress_bar.hide()
return
else:
self.total_bytes = data["filesize"]
self.progress_bar.setMinimum(0)
self.progress_bar.setMaximum(data["filesize"])
self.progress_bar.total_bytes = data["filesize"]
# Start at 0
self.update(0)
def update(self, downloaded_bytes):
self.downloaded_bytes = downloaded_bytes
self.progress_bar.setValue(downloaded_bytes)
if downloaded_bytes == self.progress_bar.total_bytes:
self.status_code_label.setText("200")
self.status_code_label.setStyleSheet(
self.common.gui.css["history_individual_file_status_code_label_2xx"]
)
self.progress_bar.hide()
self.status = HistoryItem.STATUS_FINISHED
else:
elapsed = time.time() - self.started
if elapsed < 10:
# Wait a couple of seconds for the download rate to stabilize.
# This prevents a "Windows copy dialog"-esque experience at
# the beginning of the download.
pb_fmt = strings._("gui_all_modes_progress_starting").format(
self.common.human_readable_filesize(downloaded_bytes)
)
else:
pb_fmt = strings._("gui_all_modes_progress_eta").format(
self.common.human_readable_filesize(downloaded_bytes),
self.estimated_time_remaining,
)
self.progress_bar.setFormat(pb_fmt)
def cancel(self):
self.progress_bar.setFormat(strings._("gui_canceled"))
self.status = HistoryItem.STATUS_CANCELED
@property
def estimated_time_remaining(self):
return self.common.estimated_time_remaining(
self.downloaded_bytes, self.total_bytes, self.started
)
class HistoryItemList(QtWidgets.QScrollArea):
"""
List of items
"""
def __init__(self, common):
super(HistoryItemList, self).__init__()
self.common = common
self.items = {}
# The layout that holds all of the items
self.items_layout = QtWidgets.QVBoxLayout()
self.items_layout.setContentsMargins(0, 0, 0, 0)
self.items_layout.setSizeConstraint(QtWidgets.QLayout.SetMinAndMaxSize)
# Wrapper layout that also contains a stretch
wrapper_layout = QtWidgets.QVBoxLayout()
wrapper_layout.setSizeConstraint(QtWidgets.QLayout.SetMinAndMaxSize)
wrapper_layout.addLayout(self.items_layout)
wrapper_layout.addStretch()
# The internal widget of the scroll area
widget = QtWidgets.QWidget()
widget.setLayout(wrapper_layout)
self.setWidget(widget)
self.setWidgetResizable(True)
# Other scroll area settings
self.setBackgroundRole(QtGui.QPalette.Light)
self.verticalScrollBar().rangeChanged.connect(self.resizeScroll)
def resizeScroll(self, minimum, maximum):
"""
Scroll to the bottom of the window when the range changes.
"""
self.verticalScrollBar().setValue(maximum)
def add(self, id, item):
"""
Add a new item. Override this method.
"""
self.items[id] = item
self.items_layout.addWidget(item)
def update(self, id, data):
"""
Update an item. Override this method.
"""
if id in self.items:
self.items[id].update(data)
def cancel(self, id):
"""
Cancel an item. Override this method.
"""
if id in self.items:
self.items[id].cancel()
def reset(self):
"""
Reset all items, emptying the list. Override this method.
"""
for key, item in self.items.copy().items():
self.items_layout.removeWidget(item)
item.close()
del self.items[key]
class History(QtWidgets.QWidget):
"""
A history of what's happened so far in this mode. This contains an internal
object full of a scrollable list of items.
"""
def __init__(self, common, empty_image, empty_text, header_text, mode=""):
super(History, self).__init__()
self.common = common
self.mode = mode
self.setMinimumWidth(350)
# In progress and completed counters
self.in_progress_count = 0
self.completed_count = 0
self.requests_count = 0
# In progress, completed, and requests labels
self.in_progress_label = QtWidgets.QLabel()
self.in_progress_label.setStyleSheet(self.common.gui.css["mode_info_label"])
self.completed_label = QtWidgets.QLabel()
self.completed_label.setStyleSheet(self.common.gui.css["mode_info_label"])
self.requests_label = QtWidgets.QLabel()
self.requests_label.setStyleSheet(self.common.gui.css["mode_info_label"])
# Header
self.header_label = QtWidgets.QLabel(header_text)
self.header_label.setStyleSheet(self.common.gui.css["downloads_uploads_label"])
self.clear_button = QtWidgets.QPushButton(
strings._("gui_all_modes_clear_history")
)
self.clear_button.setStyleSheet(self.common.gui.css["downloads_uploads_clear"])
self.clear_button.setFlat(True)
self.clear_button.clicked.connect(self.reset)
header_layout = QtWidgets.QHBoxLayout()
header_layout.addWidget(self.header_label)
header_layout.addStretch()
header_layout.addWidget(self.in_progress_label)
header_layout.addWidget(self.completed_label)
header_layout.addWidget(self.requests_label)
header_layout.addWidget(self.clear_button)
# When there are no items
self.empty_image = QtWidgets.QLabel()
self.empty_image.setAlignment(QtCore.Qt.AlignCenter)
self.empty_image.setPixmap(empty_image)
self.empty_text = QtWidgets.QLabel(empty_text)
self.empty_text.setAlignment(QtCore.Qt.AlignCenter)
self.empty_text.setStyleSheet(
self.common.gui.css["downloads_uploads_empty_text"]
)
empty_layout = QtWidgets.QVBoxLayout()
empty_layout.addStretch()
empty_layout.addWidget(self.empty_image)
empty_layout.addWidget(self.empty_text)
empty_layout.addStretch()
self.empty = QtWidgets.QWidget()
self.empty.setStyleSheet(self.common.gui.css["downloads_uploads_empty"])
self.empty.setLayout(empty_layout)
# When there are items
self.item_list = HistoryItemList(self.common)
self.not_empty_layout = QtWidgets.QVBoxLayout()
self.not_empty_layout.addLayout(header_layout)
self.not_empty_layout.addWidget(self.item_list)
self.not_empty = QtWidgets.QWidget()
self.not_empty.setLayout(self.not_empty_layout)
# Layout
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(self.empty)
layout.addWidget(self.not_empty)
self.setLayout(layout)
# Reset once at the beginning
self.reset()
def add(self, id, item):
"""
Add a new item.
"""
self.common.log("History", "add", f"id: {id}, item: {item}")
# Hide empty, show not empty
self.empty.hide()
self.not_empty.show()
# Add it to the list
self.item_list.add(id, item)
def update(self, id, data):
"""
Update an item.
"""
self.item_list.update(id, data)
def cancel(self, id):
"""
Cancel an item.
"""
self.item_list.cancel(id)
def reset(self):
"""
Reset all items.
"""
self.item_list.reset()
if len(self.item_list.items) == 0:
# Hide not empty, show empty
self.not_empty.hide()
self.empty.show()
# Reset in-progress counter
self.in_progress_count = 0
self.update_in_progress()
# Reset completed counter
self.completed_count = 0
self.update_completed()
# Reset web requests counter
self.requests_count = 0
self.update_requests()
def update_completed(self):
"""
Update the 'completed' widget.
"""
if self.completed_count == 0:
image = self.common.get_resource_path("images/history_completed_none.png")
else:
image = self.common.get_resource_path("images/history_completed.png")
self.completed_label.setText(f'<img src="{image}" /> {self.completed_count}')
self.completed_label.setToolTip(
strings._("history_completed_tooltip").format(self.completed_count)
)
def update_in_progress(self):
"""
Update the 'in progress' widget.
"""
if self.in_progress_count == 0:
image = self.common.get_resource_path("images/history_in_progress_none.png")
else:
image = self.common.get_resource_path("images/history_in_progress.png")
self.in_progress_label.setText(
f'<img src="{image}" /> {self.in_progress_count}'
)
self.in_progress_label.setToolTip(
strings._("history_in_progress_tooltip").format(self.in_progress_count)
)
def update_requests(self):
"""
Update the 'web requests' widget.
"""
if self.requests_count == 0:
image = self.common.get_resource_path("images/history_requests_none.png")
else:
image = self.common.get_resource_path("images/history_requests.png")
self.requests_label.setText(f'<img src="{image}" /> {self.requests_count}')
self.requests_label.setToolTip(
strings._("history_requests_tooltip").format(self.requests_count)
)
class ToggleHistory(QtWidgets.QPushButton):
"""
Widget for toggling showing or hiding the history, as well as keeping track
of the indicator counter if it's hidden
"""
def __init__(self, common, current_mode, history_widget, icon, selected_icon):
super(ToggleHistory, self).__init__()
self.common = common
self.current_mode = current_mode
self.history_widget = history_widget
self.icon = icon
self.selected_icon = selected_icon
# Toggle button
self.setDefault(False)
self.setFixedWidth(35)
self.setFixedHeight(30)
self.setFlat(True)
self.setIcon(icon)
self.clicked.connect(self.toggle_clicked)
# Keep track of indicator
self.indicator_count = 0
self.indicator_label = QtWidgets.QLabel(parent=self)
self.indicator_label.setStyleSheet(
self.common.gui.css["download_uploads_indicator"]
)
self.update_indicator()
def update_indicator(self, increment=False):
"""
Update the display of the indicator count. If increment is True, then
only increment the counter if History is hidden.
"""
if increment and not self.history_widget.isVisible():
self.indicator_count += 1
self.indicator_label.setText(str(self.indicator_count))
if self.indicator_count == 0:
self.indicator_label.hide()
else:
size = self.indicator_label.sizeHint()
self.indicator_label.setGeometry(
35 - size.width(), 0, size.width(), size.height()
)
self.indicator_label.show()
def toggle_clicked(self):
"""
Toggle showing and hiding the history widget
"""
self.common.log("ToggleHistory", "toggle_clicked")
if self.history_widget.isVisible():
self.history_widget.hide()
self.setIcon(self.icon)
self.setFlat(True)
else:
self.history_widget.show()
self.setIcon(self.selected_icon)
self.setFlat(False)
# Reset the indicator count
self.indicator_count = 0
self.update_indicator()