diff options
Diffstat (limited to 'desktop/onionshare/tab/mode')
-rw-r--r-- | desktop/onionshare/tab/mode/__init__.py | 576 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/chat_mode/__init__.py | 159 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/file_selection.py | 534 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/history.py | 893 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/mode_settings_widget.py | 288 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/receive_mode/__init__.py | 427 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/share_mode/__init__.py | 478 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/share_mode/threads.py | 62 | ||||
-rw-r--r-- | desktop/onionshare/tab/mode/website_mode/__init__.py | 388 |
9 files changed, 3805 insertions, 0 deletions
diff --git a/desktop/onionshare/tab/mode/__init__.py b/desktop/onionshare/tab/mode/__init__.py new file mode 100644 index 00000000..c9b5cad1 --- /dev/null +++ b/desktop/onionshare/tab/mode/__init__.py @@ -0,0 +1,576 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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/>. +""" + +from PySide2 import QtCore, QtWidgets + +from onionshare_cli.common import AutoStopTimer + +from .history import IndividualFileHistoryItem +from .mode_settings_widget import ModeSettingsWidget + +from ..server_status import ServerStatus +from ... import strings +from ...threads import OnionThread, AutoStartTimer +from ...widgets import Alert, MinimumSizeWidget + + +class Mode(QtWidgets.QWidget): + """ + The class that all modes inherit from + """ + + start_server_finished = QtCore.Signal() + stop_server_finished = QtCore.Signal() + starting_server_step2 = QtCore.Signal() + starting_server_step3 = QtCore.Signal() + starting_server_error = QtCore.Signal(str) + starting_server_early = QtCore.Signal() + set_server_active = QtCore.Signal(bool) + change_persistent = QtCore.Signal(int, bool) + + def __init__(self, tab): + super(Mode, self).__init__() + self.tab = tab + self.settings = tab.settings + + self.common = tab.common + self.qtapp = self.common.gui.qtapp + self.app = tab.app + + self.status_bar = tab.status_bar + self.server_status_label = tab.status_bar.server_status_label + self.system_tray = tab.system_tray + + self.filenames = tab.filenames + + # The web object gets created in init() + self.web = None + + # Threads start out as None + self.onion_thread = None + self.web_thread = None + self.startup_thread = None + + # Mode settings widget + self.mode_settings_widget = ModeSettingsWidget( + self.common, self.tab, self.settings + ) + self.mode_settings_widget.change_persistent.connect(self.change_persistent) + + # Server status + self.server_status = ServerStatus( + self.common, + self.qtapp, + self.app, + self.settings, + self.mode_settings_widget, + None, + self.common.gui.local_only, + ) + self.server_status.server_started.connect(self.start_server) + self.server_status.server_stopped.connect(self.stop_server) + self.server_status.server_canceled.connect(self.cancel_server) + self.start_server_finished.connect(self.server_status.start_server_finished) + self.stop_server_finished.connect(self.server_status.stop_server_finished) + self.starting_server_step2.connect(self.start_server_step2) + self.starting_server_step3.connect(self.start_server_step3) + self.starting_server_early.connect(self.start_server_early) + self.starting_server_error.connect(self.start_server_error) + + # Primary action + # Note: It's up to the downstream Mode to add this to its layout + self.primary_action_layout = QtWidgets.QVBoxLayout() + self.primary_action_layout.addWidget(self.mode_settings_widget) + self.primary_action = QtWidgets.QWidget() + self.primary_action.setLayout(self.primary_action_layout) + + # It's up to the downstream Mode to add stuff to self.content_layout + # self.content_layout shows the actual content of the mode + # self.tor_not_connected_layout is displayed when Tor isn't connected + self.content_layout = QtWidgets.QVBoxLayout() + self.content_widget = QtWidgets.QWidget() + self.content_widget.setLayout(self.content_layout) + + tor_not_connected_label = QtWidgets.QLabel( + strings._("mode_tor_not_connected_label") + ) + tor_not_connected_label.setAlignment(QtCore.Qt.AlignHCenter) + tor_not_connected_label.setStyleSheet( + self.common.gui.css["tor_not_connected_label"] + ) + self.tor_not_connected_layout = QtWidgets.QVBoxLayout() + self.tor_not_connected_layout.addStretch() + self.tor_not_connected_layout.addWidget(tor_not_connected_label) + self.tor_not_connected_layout.addWidget(MinimumSizeWidget(700, 0)) + self.tor_not_connected_layout.addStretch() + self.tor_not_connected_widget = QtWidgets.QWidget() + self.tor_not_connected_widget.setLayout(self.tor_not_connected_layout) + + self.wrapper_layout = QtWidgets.QVBoxLayout() + self.wrapper_layout.addWidget(self.content_widget) + self.wrapper_layout.addWidget(self.tor_not_connected_widget) + self.setLayout(self.wrapper_layout) + + if self.common.gui.onion.connected_to_tor: + self.tor_connection_started() + else: + self.tor_connection_stopped() + + def init(self): + """ + Add custom initialization here. + """ + pass + + def get_type(self): + """ + Returns the type of mode as a string (e.g. "share", "receive", etc.) + """ + pass + + def human_friendly_time(self, secs): + """ + Returns a human-friendly time delta from given seconds. + """ + days = secs // 86400 + hours = (secs - days * 86400) // 3600 + minutes = (secs - days * 86400 - hours * 3600) // 60 + seconds = secs - days * 86400 - hours * 3600 - minutes * 60 + if not seconds: + seconds = "0" + result = ( + (f"{days}{strings._('days_first_letter')}, " if days else "") + + (f"{hours}{strings._('hours_first_letter')}, " if hours else "") + + (f"{minutes}{strings._('minutes_first_letter')}, " if minutes else "") + + f"{seconds}{strings._('seconds_first_letter')}" + ) + + return result + + def timer_callback(self): + """ + This method is called regularly on a timer. + """ + # If this is a scheduled share, display the countdown til the share starts + if self.server_status.status == ServerStatus.STATUS_WORKING: + if self.settings.get("general", "autostart_timer"): + now = QtCore.QDateTime.currentDateTime() + if self.server_status.local_only: + seconds_remaining = now.secsTo( + self.mode_settings_widget.autostart_timer_widget.dateTime() + ) + else: + seconds_remaining = now.secsTo( + self.server_status.autostart_timer_datetime.replace( + second=0, microsecond=0 + ) + ) + # Update the server button + if seconds_remaining > 0: + self.server_status.server_button.setText( + strings._("gui_waiting_to_start").format( + self.human_friendly_time(seconds_remaining) + ) + ) + else: + if self.common.platform == "Windows" or self.settings.get( + "general", "autostart_timer" + ): + self.server_status.server_button.setText( + strings._("gui_please_wait") + ) + else: + self.server_status.server_button.setText( + strings._("gui_please_wait_no_button") + ) + + # If the auto-stop timer has stopped, stop the server + if self.server_status.status == ServerStatus.STATUS_STARTED: + if self.app.autostop_timer_thread and self.settings.get( + "general", "autostop_timer" + ): + if self.autostop_timer_datetime_delta > 0: + now = QtCore.QDateTime.currentDateTime() + seconds_remaining = now.secsTo( + self.server_status.autostop_timer_datetime + ) + + # Update the server button + server_button_text = self.get_stop_server_autostop_timer_text() + self.server_status.server_button.setText( + server_button_text.format( + self.human_friendly_time(seconds_remaining) + ) + ) + + self.status_bar.clearMessage() + if not self.app.autostop_timer_thread.is_alive(): + self.autostop_timer_finished_should_stop_server() + + def timer_callback_custom(self): + """ + Add custom timer code. + """ + pass + + def get_stop_server_autostop_timer_text(self): + """ + Return the string to put on the stop server button, if there's an auto-stop timer + """ + pass + + def autostop_timer_finished_should_stop_server(self): + """ + The auto-stop timer expired, should we stop the server? Returns a bool + """ + pass + + def start_server(self): + """ + Start the onionshare server. This uses multiple threads to start the Tor onion + server and the web app. + """ + self.common.log("Mode", "start_server") + + self.start_server_custom() + self.set_server_active.emit(True) + + # Clear the status bar + self.status_bar.clearMessage() + self.server_status_label.setText("") + + # Hide the mode settings + self.mode_settings_widget.hide() + + # Ensure we always get a new random port each time we might launch an OnionThread + self.app.port = None + + # Start the onion thread. If this share was scheduled for a future date, + # the OnionThread will start and exit 'early' to obtain the port, password + # and onion address, but it will not start the WebThread yet. + if self.settings.get("general", "autostart_timer"): + self.start_onion_thread(obtain_onion_early=True) + self.common.log("Mode", "start_server", "Starting auto-start timer") + self.startup_thread = AutoStartTimer(self) + # Once the timer has finished, start the real share, with a WebThread + self.startup_thread.success.connect(self.start_scheduled_service) + self.startup_thread.error.connect(self.start_server_error) + self.startup_thread.canceled = False + self.startup_thread.start() + else: + self.start_onion_thread() + + def start_onion_thread(self, obtain_onion_early=False): + # If we tried to start with Client Auth and our Tor is too old to support it, + # bail out early + if ( + not self.server_status.local_only + and not self.app.onion.supports_stealth + and not self.settings.get("general", "public") + ): + self.stop_server() + self.start_server_error(strings._("gui_server_doesnt_support_stealth")) + else: + self.common.log("Mode", "start_server", "Starting an onion thread") + self.obtain_onion_early = obtain_onion_early + self.onion_thread = OnionThread(self) + self.onion_thread.success.connect(self.starting_server_step2.emit) + self.onion_thread.success_early.connect(self.starting_server_early.emit) + self.onion_thread.error.connect(self.starting_server_error.emit) + self.onion_thread.start() + + def start_scheduled_service(self, obtain_onion_early=False): + # We start a new OnionThread with the saved scheduled key from settings + self.common.settings.load() + self.obtain_onion_early = obtain_onion_early + self.common.log("Mode", "start_server", "Starting a scheduled onion thread") + self.onion_thread = OnionThread(self) + self.onion_thread.success.connect(self.starting_server_step2.emit) + self.onion_thread.error.connect(self.starting_server_error.emit) + self.onion_thread.start() + + def start_server_custom(self): + """ + Add custom initialization here. + """ + pass + + def start_server_early(self): + """ + An 'early' start of an onion service in order to obtain the onion + address for a scheduled start. Shows the onion address in the UI + in advance of actually starting the share. + """ + self.server_status.show_url() + + def start_server_step2(self): + """ + Step 2 in starting the onionshare server. + """ + self.common.log("Mode", "start_server_step2") + + self.start_server_step2_custom() + + # Nothing to do here. + + # start_server_step2_custom has call these to move on: + # self.starting_server_step3.emit() + # self.start_server_finished.emit() + + def start_server_step2_custom(self): + """ + Add custom initialization here. + """ + pass + + def start_server_step3(self): + """ + Step 3 in starting the onionshare server. + """ + self.common.log("Mode", "start_server_step3") + + self.start_server_step3_custom() + + if self.settings.get("general", "autostop_timer"): + # Convert the date value to seconds between now and then + now = QtCore.QDateTime.currentDateTime() + self.autostop_timer_datetime_delta = now.secsTo( + self.server_status.autostop_timer_datetime + ) + # Start the auto-stop timer + if self.autostop_timer_datetime_delta > 0: + self.app.autostop_timer_thread = AutoStopTimer( + self.common, self.autostop_timer_datetime_delta + ) + self.app.autostop_timer_thread.start() + # The auto-stop timer has actually already passed since the user clicked Start. Probably the Onion service took too long to start. + else: + self.stop_server() + self.start_server_error( + strings._("gui_server_started_after_autostop_timer") + ) + + def start_server_step3_custom(self): + """ + Add custom initialization here. + """ + pass + + def start_server_error(self, error): + """ + If there's an error when trying to start the onion service + """ + self.common.log("Mode", "start_server_error") + + Alert(self.common, error, QtWidgets.QMessageBox.Warning) + self.set_server_active.emit(False) + self.server_status.stop_server() + self.status_bar.clearMessage() + + self.start_server_error_custom() + + def start_server_error_custom(self): + """ + Add custom initialization here. + """ + pass + + def cancel_server(self): + """ + Cancel the server while it is preparing to start + """ + self.cancel_server_custom() + if self.startup_thread: + self.common.log("Mode", "cancel_server: quitting startup thread") + self.startup_thread.canceled = True + self.app.onion.scheduled_key = None + self.app.onion.scheduled_auth_cookie = None + self.startup_thread.quit() + + # Canceling only works in Windows + # https://github.com/onionshare/onionshare/issues/1371 + if self.common.platform == "Windows": + if self.onion_thread: + self.common.log("Mode", "cancel_server: quitting onion thread") + self.onion_thread.terminate() + self.onion_thread.wait() + if self.web_thread: + self.common.log("Mode", "cancel_server: quitting web thread") + self.web_thread.terminate() + self.web_thread.wait() + + self.stop_server() + + def cancel_server_custom(self): + """ + Add custom initialization here. + """ + pass + + def stop_server(self): + """ + Stop the onionshare server. + """ + self.common.log("Mode", "stop_server") + + if self.server_status.status != ServerStatus.STATUS_STOPPED: + try: + self.web.stop(self.app.port) + except Exception: + # Probably we had no port to begin with (Onion service didn't start) + pass + self.web.cleanup() + + self.stop_server_custom() + + self.set_server_active.emit(False) + self.stop_server_finished.emit() + + # Show the mode settings + self.mode_settings_widget.show() + + def stop_server_custom(self): + """ + Add custom initialization here. + """ + pass + + def handle_tor_broke(self): + """ + Handle connection from Tor breaking. + """ + if self.server_status.status != ServerStatus.STATUS_STOPPED: + self.server_status.stop_server() + self.handle_tor_broke_custom() + + def handle_tor_broke_custom(self): + """ + Add custom initialization here. + """ + pass + + # Handle web server events + + def handle_request_load(self, event): + """ + Handle REQUEST_LOAD event. + """ + pass + + def handle_request_started(self, event): + """ + Handle REQUEST_STARTED event. + """ + pass + + def handle_request_progress(self, event): + """ + Handle REQUEST_PROGRESS event. + """ + pass + + def handle_request_canceled(self, event): + """ + Handle REQUEST_CANCELED event. + """ + pass + + def handle_request_upload_includes_message(self, event): + """ + Handle REQUEST_UPLOAD_INCLUDES_MESSAGE event. + """ + pass + + def handle_request_upload_file_renamed(self, event): + """ + Handle REQUEST_UPLOAD_FILE_RENAMED event. + """ + pass + + def handle_request_upload_message(self, event): + """ + Handle REQUEST_UPLOAD_MESSAGE event. + """ + pass + + def handle_request_upload_set_dir(self, event): + """ + Handle REQUEST_UPLOAD_SET_DIR event. + """ + pass + + def handle_request_upload_finished(self, event): + """ + Handle REQUEST_UPLOAD_FINISHED event. + """ + pass + + def handle_request_upload_canceled(self, event): + """ + Handle REQUEST_UPLOAD_CANCELED event. + """ + pass + + def handle_request_individual_file_started(self, event): + """ + Handle REQUEST_INDVIDIDUAL_FILES_STARTED event. + Used in both Share and Website modes, so implemented here. + """ + self.toggle_history.update_indicator(True) + self.history.requests_count += 1 + self.history.update_requests() + + item = IndividualFileHistoryItem(self.common, event["data"], event["path"]) + self.history.add(event["data"]["id"], item) + + def handle_request_individual_file_progress(self, event): + """ + Handle REQUEST_INDVIDIDUAL_FILES_PROGRESS event. + Used in both Share and Website modes, so implemented here. + """ + self.history.update(event["data"]["id"], event["data"]["bytes"]) + + if self.server_status.status == self.server_status.STATUS_STOPPED: + self.history.cancel(event["data"]["id"]) + + def handle_request_individual_file_canceled(self, event): + """ + Handle REQUEST_INDVIDIDUAL_FILES_CANCELED event. + Used in both Share and Website modes, so implemented here. + """ + self.history.cancel(event["data"]["id"]) + + def tor_connection_started(self): + """ + This is called on every Mode when Tor is connected + """ + self.content_widget.show() + self.tor_not_connected_widget.hide() + + def tor_connection_stopped(self): + """ + This is called on every Mode when Tor is disconnected + """ + if self.common.gui.local_only: + self.tor_connection_started() + return + + self.content_widget.hide() + self.tor_not_connected_widget.show() diff --git a/desktop/onionshare/tab/mode/chat_mode/__init__.py b/desktop/onionshare/tab/mode/chat_mode/__init__.py new file mode 100644 index 00000000..1081fe9d --- /dev/null +++ b/desktop/onionshare/tab/mode/chat_mode/__init__.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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/>. +""" + +from PySide2 import QtCore, QtWidgets, QtGui + +from onionshare_cli.web import Web + +from .. import Mode +from .... import strings +from ....widgets import MinimumSizeWidget +from ....gui_common import GuiCommon + + +class ChatMode(Mode): + """ + Parts of the main window UI for sharing files. + """ + + success = QtCore.Signal() + error = QtCore.Signal(str) + + def init(self): + """ + Custom initialization for ChatMode. + """ + # Create the Web object + self.web = Web(self.common, True, self.settings, "chat") + + # Chat image + self.image_label = QtWidgets.QLabel() + self.image_label.setPixmap( + QtGui.QPixmap.fromImage( + QtGui.QImage( + GuiCommon.get_resource_path( + "images/{}_mode_chat.png".format(self.common.gui.color_mode) + ) + ) + ) + ) + self.image_label.setFixedSize(300, 300) + image_layout = QtWidgets.QVBoxLayout() + image_layout.addStretch() + image_layout.addWidget(self.image_label) + image_layout.addStretch() + self.image = QtWidgets.QWidget() + self.image.setLayout(image_layout) + + # Set title placeholder + self.mode_settings_widget.title_lineedit.setPlaceholderText( + strings._("gui_tab_name_chat") + ) + + # Server status + self.server_status.set_mode("chat") + self.server_status.server_started_finished.connect(self.update_primary_action) + self.server_status.server_stopped.connect(self.update_primary_action) + self.server_status.server_canceled.connect(self.update_primary_action) + # Tell server_status about web, then update + self.server_status.web = self.web + self.server_status.update() + + # Header + header_label = QtWidgets.QLabel(strings._("gui_new_tab_chat_button")) + header_label.setStyleSheet(self.common.gui.css["mode_header_label"]) + + # Top bar + top_bar_layout = QtWidgets.QHBoxLayout() + # Add space at the top, same height as the toggle history bar in other modes + top_bar_layout.addWidget(MinimumSizeWidget(0, 30)) + + # Main layout + self.main_layout = QtWidgets.QVBoxLayout() + self.main_layout.addLayout(top_bar_layout) + self.main_layout.addWidget(header_label) + self.main_layout.addWidget(self.primary_action, stretch=1) + self.main_layout.addWidget(self.server_status) + self.main_layout.addWidget(MinimumSizeWidget(700, 0)) + + # Column layout + self.column_layout = QtWidgets.QHBoxLayout() + self.column_layout.addWidget(self.image) + self.column_layout.addLayout(self.main_layout) + + # Content layout + self.content_layout.addLayout(self.column_layout) + + def get_type(self): + """ + Returns the type of mode as a string (e.g. "share", "receive", etc.) + """ + return "chat" + + def get_stop_server_autostop_timer_text(self): + """ + Return the string to put on the stop server button, if there's an auto-stop timer + """ + return strings._("gui_share_stop_server_autostop_timer") + + def autostop_timer_finished_should_stop_server(self): + """ + The auto-stop timer expired, should we stop the server? Returns a bool + """ + + self.server_status.stop_server() + self.server_status_label.setText(strings._("close_on_autostop_timer")) + return True + + def start_server_custom(self): + """ + Starting the server. + """ + # Reset web counters + self.web.chat_mode.cur_history_id = 0 + + def start_server_step2_custom(self): + """ + Step 2 in starting the server. Zipping up files. + """ + # Continue + self.starting_server_step3.emit() + self.start_server_finished.emit() + + def cancel_server_custom(self): + """ + Log that the server has been cancelled + """ + self.common.log("ChatMode", "cancel_server") + + def handle_tor_broke_custom(self): + """ + Connection to Tor broke. + """ + self.primary_action.hide() + + def on_reload_settings(self): + """ + We should be ok to re-enable the 'Start Receive Mode' button now. + """ + self.primary_action.show() + + def update_primary_action(self): + self.common.log("ChatMode", "update_primary_action") diff --git a/desktop/onionshare/tab/mode/file_selection.py b/desktop/onionshare/tab/mode/file_selection.py new file mode 100644 index 00000000..1addba22 --- /dev/null +++ b/desktop/onionshare/tab/mode/file_selection.py @@ -0,0 +1,534 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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 os +from PySide2 import QtCore, QtWidgets, QtGui + +from ... import strings +from ...widgets import Alert, AddFileDialog +from ...gui_common import GuiCommon + + +class DropHereWidget(QtWidgets.QWidget): + """ + When there are no files or folders in the FileList yet, display the + 'drop files here' message and graphic. + """ + + def __init__(self, common, image_filename, header_text, w, h, parent): + super(DropHereWidget, self).__init__(parent) + self.common = common + self.setAcceptDrops(True) + + self.image_label = QtWidgets.QLabel(parent=self) + self.image_label.setPixmap( + QtGui.QPixmap.fromImage( + QtGui.QImage(GuiCommon.get_resource_path(image_filename)) + ) + ) + self.image_label.setAlignment(QtCore.Qt.AlignCenter) + self.image_label.show() + + self.header_label = QtWidgets.QLabel(parent=self) + self.header_label.setText(header_text) + self.header_label.setStyleSheet( + self.common.gui.css["share_file_selection_drop_here_header_label"] + ) + self.header_label.setAlignment(QtCore.Qt.AlignCenter) + self.header_label.show() + + self.text_label = QtWidgets.QLabel(parent=self) + self.text_label.setText(strings._("gui_drag_and_drop")) + self.text_label.setStyleSheet( + self.common.gui.css["share_file_selection_drop_here_label"] + ) + self.text_label.setAlignment(QtCore.Qt.AlignCenter) + self.text_label.show() + + self.resize(w, h) + self.hide() + + def dragEnterEvent(self, event): + self.hide() + event.accept() + + def resize(self, w, h): + self.setGeometry(0, 0, w, h) + self.image_label.setGeometry(0, 0, w, h - 100) + self.header_label.setGeometry(0, 290, w, h - 360) + self.text_label.setGeometry(0, 340, w, h - 380) + + +class DropCountLabel(QtWidgets.QLabel): + """ + While dragging files over the FileList, this counter displays the + number of files you're dragging. + """ + + def __init__(self, common, parent): + self.parent = parent + super(DropCountLabel, self).__init__(parent=parent) + + self.common = common + + self.setAcceptDrops(True) + self.setAlignment(QtCore.Qt.AlignCenter) + self.setText(strings._("gui_drag_and_drop")) + self.setStyleSheet(self.common.gui.css["share_file_selection_drop_count_label"]) + self.hide() + + def dragEnterEvent(self, event): + self.hide() + event.accept() + + +class FileList(QtWidgets.QListWidget): + """ + The list of files and folders in the GUI. + """ + + files_dropped = QtCore.Signal() + files_updated = QtCore.Signal() + + def __init__(self, common, background_image_filename, header_text, parent=None): + super(FileList, self).__init__(parent) + self.common = common + self.setAcceptDrops(True) + + self.setIconSize(QtCore.QSize(32, 32)) + self.setSortingEnabled(True) + self.setMinimumHeight(160) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + self.drop_here = DropHereWidget( + self.common, + background_image_filename, + header_text, + self.width(), + self.height(), + self, + ) + self.drop_count = DropCountLabel(self.common, self) + self.resizeEvent(None) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + + def update(self): + """ + Update the GUI elements based on the current state. + """ + # file list should have a background image if empty + if self.count() == 0: + self.drop_here.show() + else: + self.drop_here.hide() + + def server_started(self): + """ + Update the GUI when the server starts, by hiding delete buttons. + """ + self.setAcceptDrops(False) + self.setCurrentItem(None) + self.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + for index in range(self.count()): + self.item(index).item_button.hide() + + def server_stopped(self): + """ + Update the GUI when the server stops, by showing delete buttons. + """ + self.setAcceptDrops(True) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + for index in range(self.count()): + self.item(index).item_button.show() + + def resizeEvent(self, event): + """ + When the widget is resized, resize the drop files image and text. + """ + self.drop_here.resize(self.width(), self.height()) + + if self.count() > 0: + # Add and delete an empty item, to force all items to get redrawn + # This is ugly, but the only way I could figure out how to proceed + item = QtWidgets.QListWidgetItem("fake item") + self.addItem(item) + self.takeItem(self.row(item)) + self.update() + + # Extend any filenames that were truncated to fit the window + # We use 200 as a rough guess at how wide the 'file size + delete button' widget is + # and extend based on the overall width minus that amount. + for index in range(self.count()): + metrics = QtGui.QFontMetrics(self.item(index).font()) + elided = metrics.elidedText( + self.item(index).basename, QtCore.Qt.ElideRight, self.width() - 200 + ) + self.item(index).setText(elided) + + def dragEnterEvent(self, event): + """ + dragEnterEvent for dragging files and directories into the widget. + """ + # Drag and drop doesn't work in Flatpak, because of the sandbox + if self.common.is_flatpak(): + Alert(self.common, strings._("gui_dragdrop_sandbox_flatpak").format()) + event.ignore() + return + + if event.mimeData().hasUrls: + self.setStyleSheet(self.common.gui.css["share_file_list_drag_enter"]) + count = len(event.mimeData().urls()) + self.drop_count.setText(f"+{count}") + + size_hint = self.drop_count.sizeHint() + self.drop_count.setGeometry( + self.width() - size_hint.width() - 30, + self.height() - size_hint.height() - 10, + size_hint.width(), + size_hint.height(), + ) + self.drop_count.show() + event.accept() + else: + event.ignore() + + def dragLeaveEvent(self, event): + """ + dragLeaveEvent for dragging files and directories into the widget. + """ + # Drag and drop doesn't work in Flatpak, because of the sandbox + if self.common.is_flatpak(): + event.ignore() + return + + self.setStyleSheet(self.common.gui.css["share_file_list_drag_leave"]) + self.drop_count.hide() + event.accept() + self.update() + + def dragMoveEvent(self, event): + """ + dragMoveEvent for dragging files and directories into the widget. + """ + # Drag and drop doesn't work in Flatpak, because of the sandbox + if self.common.is_flatpak(): + event.ignore() + return + + if event.mimeData().hasUrls: + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + else: + event.ignore() + + def dropEvent(self, event): + """ + dropEvent for dragging files and directories into the widget. + """ + # Drag and drop doesn't work in Flatpak, because of the sandbox + if self.common.is_flatpak(): + event.ignore() + return + + if event.mimeData().hasUrls: + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + for url in event.mimeData().urls(): + filename = str(url.toLocalFile()) + self.add_file(filename) + else: + event.ignore() + + self.setStyleSheet(self.common.gui.css["share_file_list_drag_leave"]) + self.drop_count.hide() + + self.files_dropped.emit() + + def add_file(self, filename): + """ + Add a file or directory to this widget. + """ + filenames = [] + for index in range(self.count()): + filenames.append(self.item(index).filename) + + if filename not in filenames: + if not os.access(filename, os.R_OK): + Alert(self.common, strings._("not_a_readable_file").format(filename)) + return + + fileinfo = QtCore.QFileInfo(filename) + ip = QtWidgets.QFileIconProvider() + icon = ip.icon(fileinfo) + + if os.path.isfile(filename): + size_bytes = fileinfo.size() + size_readable = self.common.human_readable_filesize(size_bytes) + else: + size_bytes = self.common.dir_size(filename) + size_readable = self.common.human_readable_filesize(size_bytes) + + # Create a new item + item = QtWidgets.QListWidgetItem() + item.setIcon(icon) + item.size_bytes = size_bytes + + # Item's filename attribute and size labels + item.filename = filename + item_size = QtWidgets.QLabel(size_readable) + item_size.setStyleSheet(self.common.gui.css["share_file_list_item_size"]) + + item.basename = os.path.basename(filename.rstrip("/")) + # Use the basename as the method with which to sort the list + metrics = QtGui.QFontMetrics(item.font()) + elided = metrics.elidedText( + item.basename, QtCore.Qt.ElideRight, self.sizeHint().width() + ) + item.setData(QtCore.Qt.DisplayRole, elided) + + # Item's delete button + def delete_item(): + itemrow = self.row(item) + self.takeItem(itemrow) + self.files_updated.emit() + + item.item_button = QtWidgets.QPushButton() + item.item_button.setDefault(False) + item.item_button.setFlat(True) + item.item_button.setIcon( + QtGui.QIcon(GuiCommon.get_resource_path("images/file_delete.png")) + ) + item.item_button.clicked.connect(delete_item) + item.item_button.setSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + + # Item info widget, with a white background + item_info_layout = QtWidgets.QHBoxLayout() + item_info_layout.setContentsMargins(0, 0, 0, 0) + item_info_layout.addWidget(item_size) + item_info_layout.addWidget(item.item_button) + item_info = QtWidgets.QWidget() + item_info.setObjectName("item-info") + item_info.setLayout(item_info_layout) + + # Create the item's widget and layouts + item_hlayout = QtWidgets.QHBoxLayout() + item_hlayout.addStretch() + item_hlayout.addWidget(item_info) + widget = QtWidgets.QWidget() + widget.setLayout(item_hlayout) + + item.setSizeHint(widget.sizeHint()) + + self.addItem(item) + self.setItemWidget(item, widget) + + self.files_updated.emit() + + +class FileSelection(QtWidgets.QVBoxLayout): + """ + The list of files and folders in the GUI, as well as buttons to add and + delete the files and folders. + """ + + def __init__(self, common, background_image_filename, header_text, parent): + super(FileSelection, self).__init__() + + self.common = common + self.parent = parent + + self.server_on = False + + # File list + self.file_list = FileList(self.common, background_image_filename, header_text) + self.file_list.itemSelectionChanged.connect(self.update) + self.file_list.files_dropped.connect(self.update) + self.file_list.files_updated.connect(self.update) + + # Sandboxes (for masOS, Flatpak, etc.) need separate add files and folders buttons, in + # order to use native file selection dialogs + if self.common.platform == "Darwin" or self.common.is_flatpak(): + self.sandbox = True + else: + self.sandbox = False + + # Buttons + if self.sandbox: + # The macOS sandbox makes it so the Mac version needs separate add files + # and folders buttons, in order to use native file selection dialogs + self.add_files_button = QtWidgets.QPushButton(strings._("gui_add_files")) + self.add_files_button.clicked.connect(self.add_files) + self.add_folder_button = QtWidgets.QPushButton(strings._("gui_add_folder")) + self.add_folder_button.clicked.connect(self.add_folder) + else: + self.add_button = QtWidgets.QPushButton(strings._("gui_add")) + self.add_button.clicked.connect(self.add) + self.remove_button = QtWidgets.QPushButton(strings._("gui_remove")) + self.remove_button.clicked.connect(self.delete) + button_layout = QtWidgets.QHBoxLayout() + button_layout.addStretch() + if self.sandbox: + button_layout.addWidget(self.add_files_button) + button_layout.addWidget(self.add_folder_button) + else: + button_layout.addWidget(self.add_button) + button_layout.addWidget(self.remove_button) + + # Add the widgets + self.addWidget(self.file_list) + self.addLayout(button_layout) + + self.update() + + def update(self): + """ + Update the GUI elements based on the current state. + """ + # All buttons should be hidden if the server is on + if self.server_on: + if self.sandbox: + self.add_files_button.hide() + self.add_folder_button.hide() + else: + self.add_button.hide() + self.remove_button.hide() + else: + if self.sandbox: + self.add_files_button.show() + self.add_folder_button.show() + else: + self.add_button.show() + + # Delete button should be hidden if item isn't selected + if len(self.file_list.selectedItems()) == 0: + self.remove_button.hide() + else: + self.remove_button.show() + + # Update the file list + self.file_list.update() + + # Save the latest file list to mode settings + self.save_filenames() + + def add(self): + """ + Add button clicked. + """ + file_dialog = AddFileDialog(self.common, caption=strings._("gui_choose_items")) + if file_dialog.exec_() == QtWidgets.QDialog.Accepted: + self.common.log("FileSelection", "add", file_dialog.selectedFiles()) + for filename in file_dialog.selectedFiles(): + self.file_list.add_file(filename) + + self.file_list.setCurrentItem(None) + self.update() + + def add_files(self): + """ + Add Files button clicked. + """ + files = QtWidgets.QFileDialog.getOpenFileNames( + self.parent, caption=strings._("gui_choose_items") + ) + self.common.log("FileSelection", "add_files", files) + + filenames = files[0] + for filename in filenames: + self.file_list.add_file(filename) + + self.file_list.setCurrentItem(None) + self.update() + + def add_folder(self): + """ + Add Folder button clicked. + """ + filename = QtWidgets.QFileDialog.getExistingDirectory( + self.parent, + caption=strings._("gui_choose_items"), + options=QtWidgets.QFileDialog.ShowDirsOnly, + ) + self.common.log("FileSelection", "add_folder", filename) + if filename: + self.file_list.add_file(filename) + self.file_list.setCurrentItem(None) + self.update() + + def delete(self): + """ + Delete button clicked + """ + selected = self.file_list.selectedItems() + for item in selected: + itemrow = self.file_list.row(item) + self.file_list.takeItem(itemrow) + self.file_list.files_updated.emit() + + self.file_list.setCurrentItem(None) + self.update() + + def server_started(self): + """ + Gets called when the server starts. + """ + self.server_on = True + self.file_list.server_started() + self.update() + + def server_stopped(self): + """ + Gets called when the server stops. + """ + self.server_on = False + self.file_list.server_stopped() + self.update() + + def get_num_files(self): + """ + Returns the total number of files and folders in the list. + """ + return len(range(self.file_list.count())) + + def get_filenames(self): + """ + Return the list of file and folder names + """ + filenames = [] + for index in range(self.file_list.count()): + filenames.append(self.file_list.item(index).filename) + return filenames + + def save_filenames(self): + """ + Save the filenames to mode settings + """ + filenames = self.get_filenames() + if self.parent.tab.mode == self.common.gui.MODE_SHARE: + self.parent.settings.set("share", "filenames", filenames) + elif self.parent.tab.mode == self.common.gui.MODE_WEBSITE: + self.parent.settings.set("website", "filenames", filenames) + + def setFocus(self): + """ + Set the Qt app focus on the file selection box. + """ + self.file_list.setFocus() diff --git a/desktop/onionshare/tab/mode/history.py b/desktop/onionshare/tab/mode/history.py new file mode 100644 index 00000000..4e8fcf8e --- /dev/null +++ b/desktop/onionshare/tab/mode/history.py @@ -0,0 +1,893 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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 PySide2 import QtCore, QtWidgets, QtGui + +from ... import strings +from ...widgets import Alert +from ...gui_common import GuiCommon + + +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 / 1024) + 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 / 1024) + if (downloaded_bytes / 1024) == (self.progress_bar.total_bytes / 1024): + 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.label.setStyleSheet(self.common.gui.css["history_default_label"]) + 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(GuiCommon.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(["xdg-open", self.dir]) + except Exception: + Alert( + self.common, + strings._("gui_open_folder_error").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 ReceiveHistoryItemMessage(QtWidgets.QWidget): + def __init__( + self, + common, + ): + super(ReceiveHistoryItemMessage, self).__init__() + self.common = common + self.filename = None + + # Read message button + message_pixmap = QtGui.QPixmap.fromImage( + QtGui.QImage(GuiCommon.get_resource_path("images/open_message.png")) + ) + message_icon = QtGui.QIcon(message_pixmap) + self.message_button = QtWidgets.QPushButton( + strings._("history_receive_read_message_button") + ) + self.message_button.setStyleSheet(self.common.gui.css["receive_message_button"]) + self.message_button.clicked.connect(self.open_message) + self.message_button.setIcon(message_icon) + self.message_button.setIconSize(message_pixmap.rect().size()) + + # Layouts + layout = QtWidgets.QHBoxLayout() + layout.addWidget(self.message_button) + layout.addStretch() + self.setLayout(layout) + + self.hide() + + def set_filename(self, new_filename): + self.filename = new_filename + self.show() + + def open_message(self): + """ + Open the message in the operating system's default text editor + """ + self.common.log("ReceiveHistoryItemMessage", "open_message", self.filename) + + # Linux + if self.common.platform == "Linux" or self.common.platform == "BSD": + # If nautilus is available, open it + subprocess.Popen(["xdg-open", self.filename]) + + # macOS + elif self.common.platform == "Darwin": + subprocess.call(["open", self.filename]) + + # Windows + elif self.common.platform == "Windows": + subprocess.Popen(["notepad", self.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 + + self.common.log( + "ReceiveHistoryItem", + "__init__", + f"id={self.id} content_length={self.content_length}", + ) + + # 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"] + ) + + # The message widget, if a message was included + self.message = ReceiveHistoryItemMessage(self.common) + + # 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.message) + 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 includes_message(self, message_filename): + self.message.set_filename(message_filename) + + 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 / 1024) + self.progress_bar.setValue(total_uploaded_bytes / 1024) + + 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, + ) + + self.progress_bar.setFormat(pb_fmt) + + # 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)) + self.label.setStyleSheet(self.common.gui.css["history_default_label"]) + + 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.path_label.setTextFormat(QtCore.Qt.PlainText) + self.path_label.setStyleSheet(self.common.gui.css["history_default_label"]) + 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"] / 1024) + 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 / 1024) + if (downloaded_bytes / 1024) == (self.progress_bar.total_bytes / 1024): + 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 includes_message(self, id, message_filename): + """ + Show message button for receive mode + """ + if id in self.items: + self.items[id].includes_message(message_filename) + + 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.setStyleSheet(self.common.gui.css["downloads_uploads_not_empty"]) + 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}") + + # 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 includes_message(self, id, message_filename): + """ + Show the message button + """ + self.item_list.includes_message(id, message_filename) + + 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 = GuiCommon.get_resource_path("images/history_completed_none.png") + else: + image = GuiCommon.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 = GuiCommon.get_resource_path("images/history_in_progress_none.png") + else: + image = GuiCommon.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 = GuiCommon.get_resource_path("images/history_requests_none.png") + else: + image = GuiCommon.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() diff --git a/desktop/onionshare/tab/mode/mode_settings_widget.py b/desktop/onionshare/tab/mode/mode_settings_widget.py new file mode 100644 index 00000000..0e80023e --- /dev/null +++ b/desktop/onionshare/tab/mode/mode_settings_widget.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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/>. +""" + +from PySide2 import QtCore, QtWidgets + +from ... import strings + + +class ModeSettingsWidget(QtWidgets.QScrollArea): + """ + All of the common settings for each mode are in this widget + """ + + change_persistent = QtCore.Signal(int, bool) + + def __init__(self, common, tab, mode_settings): + super(ModeSettingsWidget, self).__init__() + self.common = common + self.tab = tab + self.settings = mode_settings + + # Downstream Mode need to fill in this layout with its settings + self.mode_specific_layout = QtWidgets.QVBoxLayout() + + # Persistent + self.persistent_checkbox = QtWidgets.QCheckBox() + self.persistent_checkbox.clicked.connect(self.persistent_checkbox_clicked) + self.persistent_checkbox.setText(strings._("mode_settings_persistent_checkbox")) + if self.settings.get("persistent", "enabled"): + self.persistent_checkbox.setCheckState(QtCore.Qt.Checked) + else: + self.persistent_checkbox.setCheckState(QtCore.Qt.Unchecked) + + # Public + self.public_checkbox = QtWidgets.QCheckBox() + self.public_checkbox.clicked.connect(self.public_checkbox_clicked) + self.public_checkbox.setText(strings._("mode_settings_public_checkbox")) + if self.settings.get("general", "public"): + self.public_checkbox.setCheckState(QtCore.Qt.Checked) + else: + self.public_checkbox.setCheckState(QtCore.Qt.Unchecked) + + # Title + title_label = QtWidgets.QLabel(strings._("mode_settings_title_label")) + self.title_lineedit = QtWidgets.QLineEdit() + self.title_lineedit.editingFinished.connect(self.title_editing_finished) + if self.settings.get("general", "title"): + self.title_lineedit.setText(self.settings.get("general", "title")) + title_layout = QtWidgets.QHBoxLayout() + title_layout.addWidget(title_label) + title_layout.addWidget(self.title_lineedit) + + # Whether or not to use an auto-start timer + self.autostart_timer_checkbox = QtWidgets.QCheckBox() + self.autostart_timer_checkbox.clicked.connect( + self.autostart_timer_checkbox_clicked + ) + self.autostart_timer_checkbox.setText( + strings._("mode_settings_autostart_timer_checkbox") + ) + if self.settings.get("general", "autostart_timer"): + self.autostart_timer_checkbox.setCheckState(QtCore.Qt.Checked) + else: + self.autostart_timer_checkbox.setCheckState(QtCore.Qt.Unchecked) + + # The autostart timer widget + self.autostart_timer_widget = QtWidgets.QDateTimeEdit() + self.autostart_timer_widget.setDisplayFormat("hh:mm A MMM d, yy") + self.autostart_timer_reset() + self.autostart_timer_widget.setCurrentSection( + QtWidgets.QDateTimeEdit.MinuteSection + ) + if self.settings.get("general", "autostart_timer"): + self.autostart_timer_widget.show() + else: + self.autostart_timer_widget.hide() + + # Autostart timer layout + autostart_timer_layout = QtWidgets.QHBoxLayout() + autostart_timer_layout.setContentsMargins(0, 0, 0, 0) + autostart_timer_layout.addWidget(self.autostart_timer_checkbox) + autostart_timer_layout.addWidget(self.autostart_timer_widget) + + # Whether or not to use an auto-stop timer + self.autostop_timer_checkbox = QtWidgets.QCheckBox() + self.autostop_timer_checkbox.clicked.connect( + self.autostop_timer_checkbox_clicked + ) + self.autostop_timer_checkbox.setText( + strings._("mode_settings_autostop_timer_checkbox") + ) + if self.settings.get("general", "autostop_timer"): + self.autostop_timer_checkbox.setCheckState(QtCore.Qt.Checked) + else: + self.autostop_timer_checkbox.setCheckState(QtCore.Qt.Unchecked) + + # The autostop timer widget + self.autostop_timer_widget = QtWidgets.QDateTimeEdit() + self.autostop_timer_widget.setDisplayFormat("hh:mm A MMM d, yy") + self.autostop_timer_reset() + self.autostop_timer_widget.setCurrentSection( + QtWidgets.QDateTimeEdit.MinuteSection + ) + if self.settings.get("general", "autostop_timer"): + self.autostop_timer_widget.show() + else: + self.autostop_timer_widget.hide() + + # Autostop timer layout + autostop_timer_layout = QtWidgets.QHBoxLayout() + autostop_timer_layout.setContentsMargins(0, 0, 0, 0) + autostop_timer_layout.addWidget(self.autostop_timer_checkbox) + autostop_timer_layout.addWidget(self.autostop_timer_widget) + + # Toggle advanced settings + self.toggle_advanced_button = QtWidgets.QPushButton() + self.toggle_advanced_button.clicked.connect(self.toggle_advanced_clicked) + self.toggle_advanced_button.setFlat(True) + self.toggle_advanced_button.setStyleSheet( + self.common.gui.css["mode_settings_toggle_advanced"] + ) + + # Advanced group itself + advanced_layout = QtWidgets.QVBoxLayout() + advanced_layout.setContentsMargins(0, 0, 0, 0) + advanced_layout.addLayout(title_layout) + advanced_layout.addLayout(autostart_timer_layout) + advanced_layout.addLayout(autostop_timer_layout) + self.advanced_widget = QtWidgets.QWidget() + self.advanced_widget.setLayout(advanced_layout) + self.advanced_widget.hide() + + layout = QtWidgets.QVBoxLayout() + layout.addLayout(self.mode_specific_layout) + layout.addWidget(self.persistent_checkbox) + layout.addWidget(self.public_checkbox) + layout.addWidget(self.advanced_widget) + layout.addWidget(self.toggle_advanced_button) + layout.addStretch() + main_widget = QtWidgets.QWidget() + main_widget.setLayout(layout) + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.setWidgetResizable(True) + self.setFrameShape(QtWidgets.QFrame.NoFrame) + self.setWidget(main_widget) + + self.update_ui() + + def update_ui(self): + # Update text on advanced group toggle button + if self.advanced_widget.isVisible(): + self.toggle_advanced_button.setText( + strings._("mode_settings_advanced_toggle_hide") + ) + else: + self.toggle_advanced_button.setText( + strings._("mode_settings_advanced_toggle_show") + ) + + def title_editing_finished(self): + if self.title_lineedit.text().strip() == "": + self.title_lineedit.setText("") + self.settings.set("general", "title", None) + if self.tab.mode == self.common.gui.MODE_SHARE: + self.tab.change_title.emit( + self.tab.tab_id, strings._("gui_tab_name_share") + ) + elif self.tab.mode == self.common.gui.MODE_RECEIVE: + self.tab.change_title.emit( + self.tab.tab_id, strings._("gui_tab_name_receive") + ) + elif self.tab.mode == self.common.gui.MODE_WEBSITE: + self.tab.change_title.emit( + self.tab.tab_id, strings._("gui_tab_name_website") + ) + elif self.tab.mode == self.common.gui.MODE_CHAT: + self.tab.change_title.emit( + self.tab.tab_id, strings._("gui_tab_name_chat") + ) + elif self.tab_mode is None: + pass + else: + title = self.title_lineedit.text() + self.settings.set("general", "title", title) + self.tab.change_title.emit(self.tab.tab_id, title) + + def persistent_checkbox_clicked(self): + self.settings.set("persistent", "enabled", self.persistent_checkbox.isChecked()) + self.settings.set("persistent", "mode", self.tab.mode) + self.change_persistent.emit( + self.tab.tab_id, self.persistent_checkbox.isChecked() + ) + + # If disabling persistence, delete the file from disk + if not self.persistent_checkbox.isChecked(): + self.settings.delete() + + def public_checkbox_clicked(self): + self.settings.set("general", "public", self.public_checkbox.isChecked()) + + def autostart_timer_checkbox_clicked(self): + self.settings.set( + "general", "autostart_timer", self.autostart_timer_checkbox.isChecked() + ) + + if self.autostart_timer_checkbox.isChecked(): + self.autostart_timer_widget.show() + else: + self.autostart_timer_widget.hide() + + def autostop_timer_checkbox_clicked(self): + self.settings.set( + "general", "autostop_timer", self.autostop_timer_checkbox.isChecked() + ) + + if self.autostop_timer_checkbox.isChecked(): + self.autostop_timer_widget.show() + else: + self.autostop_timer_widget.hide() + + def toggle_advanced_clicked(self): + if self.advanced_widget.isVisible(): + self.advanced_widget.hide() + else: + self.advanced_widget.show() + + self.update_ui() + + def autostart_timer_reset(self): + """ + Reset the auto-start timer in the UI after stopping a share + """ + if self.common.gui.local_only: + # For testing + self.autostart_timer_widget.setDateTime( + QtCore.QDateTime.currentDateTime().addSecs(15) + ) + self.autostart_timer_widget.setMinimumDateTime( + QtCore.QDateTime.currentDateTime() + ) + else: + self.autostart_timer_widget.setDateTime( + QtCore.QDateTime.currentDateTime().addSecs( + 300 + ) # 5 minutes in the future + ) + self.autostart_timer_widget.setMinimumDateTime( + QtCore.QDateTime.currentDateTime().addSecs(60) + ) + + def autostop_timer_reset(self): + """ + Reset the auto-stop timer in the UI after stopping a share + """ + if self.common.gui.local_only: + # For testing + self.autostop_timer_widget.setDateTime( + QtCore.QDateTime.currentDateTime().addSecs(15) + ) + self.autostop_timer_widget.setMinimumDateTime( + QtCore.QDateTime.currentDateTime() + ) + else: + self.autostop_timer_widget.setDateTime( + QtCore.QDateTime.currentDateTime().addSecs(300) + ) + self.autostop_timer_widget.setMinimumDateTime( + QtCore.QDateTime.currentDateTime().addSecs(60) + ) diff --git a/desktop/onionshare/tab/mode/receive_mode/__init__.py b/desktop/onionshare/tab/mode/receive_mode/__init__.py new file mode 100644 index 00000000..b2b2fc5a --- /dev/null +++ b/desktop/onionshare/tab/mode/receive_mode/__init__.py @@ -0,0 +1,427 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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 os +from PySide2 import QtCore, QtWidgets, QtGui + +from onionshare_cli.web import Web + +from ..history import History, ToggleHistory, ReceiveHistoryItem +from .. import Mode +from .... import strings +from ....widgets import MinimumSizeWidget, Alert +from ....gui_common import GuiCommon + + +class ReceiveMode(Mode): + """ + Parts of the main window UI for receiving files. + """ + + def init(self): + """ + Custom initialization for ReceiveMode. + """ + # Create the Web object + self.web = Web(self.common, True, self.settings, "receive") + + # Receive image + self.image_label = QtWidgets.QLabel() + self.image_label.setPixmap( + QtGui.QPixmap.fromImage( + QtGui.QImage( + GuiCommon.get_resource_path( + "images/{}_mode_receive.png".format(self.common.gui.color_mode) + ) + ) + ) + ) + self.image_label.setFixedSize(250, 250) + image_layout = QtWidgets.QVBoxLayout() + image_layout.addWidget(self.image_label) + self.image = QtWidgets.QWidget() + self.image.setLayout(image_layout) + + # Settings + + # Data dir + data_dir_label = QtWidgets.QLabel( + strings._("mode_settings_receive_data_dir_label") + ) + self.data_dir_lineedit = QtWidgets.QLineEdit() + self.data_dir_lineedit.setReadOnly(True) + self.data_dir_lineedit.setText(self.settings.get("receive", "data_dir")) + data_dir_button = QtWidgets.QPushButton( + strings._("mode_settings_receive_data_dir_browse_button") + ) + data_dir_button.clicked.connect(self.data_dir_button_clicked) + data_dir_layout = QtWidgets.QHBoxLayout() + data_dir_layout.addWidget(data_dir_label) + data_dir_layout.addWidget(self.data_dir_lineedit) + data_dir_layout.addWidget(data_dir_button) + self.mode_settings_widget.mode_specific_layout.addLayout(data_dir_layout) + + # Disable text or files + self.disable_text_checkbox = self.settings.get("receive", "disable_files") + self.disable_text_checkbox = QtWidgets.QCheckBox() + self.disable_text_checkbox.clicked.connect(self.disable_text_checkbox_clicked) + self.disable_text_checkbox.setText( + strings._("mode_settings_receive_disable_text_checkbox") + ) + self.disable_files_checkbox = self.settings.get("receive", "disable_files") + self.disable_files_checkbox = QtWidgets.QCheckBox() + self.disable_files_checkbox.clicked.connect(self.disable_files_checkbox_clicked) + self.disable_files_checkbox.setText( + strings._("mode_settings_receive_disable_files_checkbox") + ) + disable_layout = QtWidgets.QHBoxLayout() + disable_layout.addWidget(self.disable_text_checkbox) + disable_layout.addWidget(self.disable_files_checkbox) + disable_layout.addStretch() + self.mode_settings_widget.mode_specific_layout.addLayout(disable_layout) + + # Webhook URL + webhook_url = self.settings.get("receive", "webhook_url") + self.webhook_url_checkbox = QtWidgets.QCheckBox() + self.webhook_url_checkbox.clicked.connect(self.webhook_url_checkbox_clicked) + self.webhook_url_checkbox.setText( + strings._("mode_settings_receive_webhook_url_checkbox") + ) + self.webhook_url_lineedit = QtWidgets.QLineEdit() + self.webhook_url_lineedit.editingFinished.connect( + self.webhook_url_editing_finished + ) + self.webhook_url_lineedit.setPlaceholderText( + "https://example.com/post-when-file-uploaded" + ) + webhook_url_layout = QtWidgets.QHBoxLayout() + webhook_url_layout.addWidget(self.webhook_url_checkbox) + webhook_url_layout.addWidget(self.webhook_url_lineedit) + if webhook_url is not None and webhook_url != "": + self.webhook_url_checkbox.setCheckState(QtCore.Qt.Checked) + self.webhook_url_lineedit.setText( + self.settings.get("receive", "webhook_url") + ) + self.show_webhook_url() + else: + self.webhook_url_checkbox.setCheckState(QtCore.Qt.Unchecked) + self.hide_webhook_url() + self.mode_settings_widget.mode_specific_layout.addLayout(webhook_url_layout) + + # Set title placeholder + self.mode_settings_widget.title_lineedit.setPlaceholderText( + strings._("gui_tab_name_receive") + ) + + # Server status + self.server_status.set_mode("receive") + self.server_status.server_started_finished.connect(self.update_primary_action) + self.server_status.server_stopped.connect(self.update_primary_action) + self.server_status.server_canceled.connect(self.update_primary_action) + + # Tell server_status about web, then update + self.server_status.web = self.web + self.server_status.update() + + # Upload history + self.history = History( + self.common, + QtGui.QPixmap.fromImage( + QtGui.QImage( + GuiCommon.get_resource_path("images/receive_icon_transparent.png") + ) + ), + strings._("gui_receive_mode_no_files"), + strings._("gui_all_modes_history"), + ) + self.history.hide() + + # Toggle history + self.toggle_history = ToggleHistory( + self.common, + self, + self.history, + QtGui.QIcon(GuiCommon.get_resource_path("images/receive_icon_toggle.png")), + QtGui.QIcon( + GuiCommon.get_resource_path("images/receive_icon_toggle_selected.png") + ), + ) + + # Header + header_label = QtWidgets.QLabel(strings._("gui_new_tab_receive_button")) + header_label.setStyleSheet(self.common.gui.css["mode_header_label"]) + + # Receive mode warning + receive_warning = QtWidgets.QLabel(strings._("gui_receive_mode_warning")) + receive_warning.setMinimumHeight(80) + receive_warning.setWordWrap(True) + + # Top bar + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addStretch() + top_bar_layout.addWidget(self.toggle_history) + + # Main layout + self.main_layout = QtWidgets.QVBoxLayout() + self.main_layout.addWidget(header_label) + self.main_layout.addWidget(receive_warning) + self.main_layout.addWidget(self.primary_action, stretch=1) + self.main_layout.addWidget(self.server_status) + + # Row layout + content_row = QtWidgets.QHBoxLayout() + content_row.addLayout(self.main_layout, stretch=1) + content_row.addWidget(self.image) + row_layout = QtWidgets.QVBoxLayout() + row_layout.addLayout(top_bar_layout) + row_layout.addLayout(content_row, stretch=1) + + # Column layout + self.column_layout = QtWidgets.QHBoxLayout() + self.column_layout.addLayout(row_layout) + self.column_layout.addWidget(self.history, stretch=1) + + # Content layout + self.content_layout.addLayout(self.column_layout) + + def get_type(self): + """ + Returns the type of mode as a string (e.g. "share", "receive", etc.) + """ + return "receive" + + def data_dir_button_clicked(self): + """ + Browse for a new OnionShare data directory, and save to tab settings + """ + data_dir = self.data_dir_lineedit.text() + selected_dir = QtWidgets.QFileDialog.getExistingDirectory( + self, strings._("mode_settings_receive_data_dir_label"), data_dir + ) + + if selected_dir: + # If we're running inside a flatpak package, the data dir must be inside ~/OnionShare + if self.common.gui.is_flatpak: + if not selected_dir.startswith(os.path.expanduser("~/OnionShare")): + Alert(self.common, strings._("gui_receive_flatpak_data_dir")) + return + + self.common.log( + "ReceiveMode", + "data_dir_button_clicked", + f"selected dir: {selected_dir}", + ) + self.data_dir_lineedit.setText(selected_dir) + self.settings.set("receive", "data_dir", selected_dir) + + def disable_text_checkbox_clicked(self): + self.settings.set( + "receive", "disable_text", self.disable_text_checkbox.isChecked() + ) + + def disable_files_checkbox_clicked(self): + self.settings.set( + "receive", "disable_files", self.disable_files_checkbox.isChecked() + ) + + def webhook_url_checkbox_clicked(self): + if self.webhook_url_checkbox.isChecked(): + if self.settings.get("receive", "webhook_url"): + self.webhook_url_lineedit.setText( + self.settings.get("receive", "webhook_url") + ) + self.show_webhook_url() + else: + self.settings.set("receive", "webhook_url", None) + self.hide_webhook_url() + + def webhook_url_editing_finished(self): + self.settings.set("receive", "webhook_url", self.webhook_url_lineedit.text()) + + def hide_webhook_url(self): + self.webhook_url_lineedit.hide() + + def show_webhook_url(self): + self.webhook_url_lineedit.show() + + def get_stop_server_autostop_timer_text(self): + """ + Return the string to put on the stop server button, if there's an auto-stop timer + """ + return strings._("gui_receive_stop_server_autostop_timer") + + def autostop_timer_finished_should_stop_server(self): + """ + The auto-stop timer expired, should we stop the server? Returns a bool + """ + # If there were no attempts to upload files, or all uploads are done, we can stop + if ( + self.web.receive_mode.cur_history_id == 0 + or not self.web.receive_mode.uploads_in_progress + ): + self.server_status.stop_server() + self.server_status_label.setText(strings._("close_on_autostop_timer")) + return True + # An upload is probably still running - hold off on stopping the share, but block new shares. + else: + self.server_status_label.setText( + strings._("gui_receive_mode_autostop_timer_waiting") + ) + self.web.receive_mode.can_upload = False + return False + + def start_server_custom(self): + """ + Starting the server. + """ + # Reset web counters + self.web.receive_mode.cur_history_id = 0 + + # Hide and reset the uploads if we have previously shared + self.reset_info_counters() + + # Set proxies for webhook URL + if self.common.gui.local_only: + self.web.proxies = None + else: + (socks_address, socks_port) = self.common.gui.onion.get_tor_socks_port() + self.web.proxies = { + "http": f"socks5h://{socks_address}:{socks_port}", + "https": f"socks5h://{socks_address}:{socks_port}", + } + + def start_server_step2_custom(self): + """ + Step 2 in starting the server. + """ + # Continue + self.starting_server_step3.emit() + self.start_server_finished.emit() + + def handle_tor_broke_custom(self): + """ + Connection to Tor broke. + """ + self.primary_action.hide() + + def handle_request_load(self, event): + """ + Handle REQUEST_LOAD event. + """ + self.system_tray.showMessage( + strings._("systray_page_loaded_title"), + strings._("systray_page_loaded_message"), + ) + + def handle_request_started(self, event): + """ + Handle REQUEST_STARTED event. + """ + item = ReceiveHistoryItem( + self.common, + event["data"]["id"], + event["data"]["content_length"], + ) + + self.history.add(event["data"]["id"], item) + self.toggle_history.update_indicator(True) + self.history.in_progress_count += 1 + self.history.update_in_progress() + + self.system_tray.showMessage( + strings._("systray_receive_started_title"), + strings._("systray_receive_started_message"), + ) + + def handle_request_progress(self, event): + """ + Handle REQUEST_PROGRESS event. + """ + self.history.update( + event["data"]["id"], + {"action": "progress", "progress": event["data"]["progress"]}, + ) + + def handle_request_upload_includes_message(self, event): + """ + Handle REQUEST_UPLOAD_INCLUDES_MESSAGE event. + """ + self.history.includes_message(event["data"]["id"], event["data"]["filename"]) + + def handle_request_upload_file_renamed(self, event): + """ + Handle REQUEST_UPLOAD_FILE_RENAMED event. + """ + self.history.update( + event["data"]["id"], + { + "action": "rename", + "old_filename": event["data"]["old_filename"], + "new_filename": event["data"]["new_filename"], + }, + ) + + def handle_request_upload_set_dir(self, event): + """ + Handle REQUEST_UPLOAD_SET_DIR event. + """ + self.history.update( + event["data"]["id"], + { + "action": "set_dir", + "filename": event["data"]["filename"], + "dir": event["data"]["dir"], + }, + ) + + def handle_request_upload_finished(self, event): + """ + Handle REQUEST_UPLOAD_FINISHED event. + """ + self.history.update(event["data"]["id"], {"action": "finished"}) + self.history.completed_count += 1 + self.history.in_progress_count -= 1 + self.history.update_completed() + self.history.update_in_progress() + + def handle_request_upload_canceled(self, event): + """ + Handle REQUEST_UPLOAD_CANCELED event. + """ + self.history.update(event["data"]["id"], {"action": "canceled"}) + self.history.in_progress_count -= 1 + self.history.update_in_progress() + + def on_reload_settings(self): + """ + We should be ok to re-enable the 'Start Receive Mode' button now. + """ + self.primary_action.show() + + def reset_info_counters(self): + """ + Set the info counters back to zero. + """ + self.history.reset() + self.toggle_history.indicator_count = 0 + self.toggle_history.update_indicator() + + def update_primary_action(self): + self.common.log("ReceiveMode", "update_primary_action") diff --git a/desktop/onionshare/tab/mode/share_mode/__init__.py b/desktop/onionshare/tab/mode/share_mode/__init__.py new file mode 100644 index 00000000..ed7f6912 --- /dev/null +++ b/desktop/onionshare/tab/mode/share_mode/__init__.py @@ -0,0 +1,478 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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 os +from PySide2 import QtCore, QtWidgets, QtGui + +from onionshare_cli.common import Common +from onionshare_cli.web import Web + +from .threads import CompressThread +from .. import Mode +from ..file_selection import FileSelection +from ..history import History, ToggleHistory, ShareHistoryItem +from .... import strings +from ....widgets import MinimumSizeWidget +from ....gui_common import GuiCommon + + +class ShareMode(Mode): + """ + Parts of the main window UI for sharing files. + """ + + def init(self): + """ + Custom initialization for ReceiveMode. + """ + # Threads start out as None + self.compress_thread = None + + # Create the Web object + self.web = Web(self.common, True, self.settings, "share") + + # Settings + self.autostop_sharing_checkbox = QtWidgets.QCheckBox() + self.autostop_sharing_checkbox.clicked.connect( + self.autostop_sharing_checkbox_clicked + ) + self.autostop_sharing_checkbox.setText( + strings._("mode_settings_share_autostop_sharing_checkbox") + ) + if self.settings.get("share", "autostop_sharing"): + self.autostop_sharing_checkbox.setCheckState(QtCore.Qt.Checked) + else: + self.autostop_sharing_checkbox.setCheckState(QtCore.Qt.Unchecked) + + self.mode_settings_widget.mode_specific_layout.addWidget( + self.autostop_sharing_checkbox + ) + + # File selection + self.file_selection = FileSelection( + self.common, + "images/{}_mode_share.png".format(self.common.gui.color_mode), + strings._("gui_new_tab_share_button"), + self, + ) + if self.filenames: + for filename in self.filenames: + self.file_selection.file_list.add_file(filename) + + # Set title placeholder + self.mode_settings_widget.title_lineedit.setPlaceholderText( + strings._("gui_tab_name_share") + ) + + # Server status + self.server_status.set_mode("share", self.file_selection) + self.server_status.server_started.connect(self.file_selection.server_started) + self.server_status.server_stopped.connect(self.file_selection.server_stopped) + self.server_status.server_stopped.connect(self.update_primary_action) + self.server_status.server_canceled.connect(self.file_selection.server_stopped) + self.server_status.server_canceled.connect(self.update_primary_action) + self.file_selection.file_list.files_updated.connect(self.server_status.update) + self.file_selection.file_list.files_updated.connect(self.update_primary_action) + # Tell server_status about web, then update + self.server_status.web = self.web + self.server_status.update() + + # Filesize warning + self.filesize_warning = QtWidgets.QLabel() + self.filesize_warning.setWordWrap(True) + self.filesize_warning.setStyleSheet( + self.common.gui.css["share_filesize_warning"] + ) + self.filesize_warning.hide() + + # Download history + self.history = History( + self.common, + QtGui.QPixmap.fromImage( + QtGui.QImage( + GuiCommon.get_resource_path("images/share_icon_transparent.png") + ) + ), + strings._("gui_share_mode_no_files"), + strings._("gui_all_modes_history"), + ) + self.history.hide() + + # Info label + self.info_label = QtWidgets.QLabel() + self.info_label.hide() + + # Delete all files button + self.remove_all_button = QtWidgets.QPushButton( + strings._("gui_file_selection_remove_all") + ) + self.remove_all_button.setFlat(True) + self.remove_all_button.setStyleSheet( + self.common.gui.css["share_delete_all_files_button"] + ) + self.remove_all_button.clicked.connect(self.delete_all) + self.remove_all_button.hide() + + # Toggle history + self.toggle_history = ToggleHistory( + self.common, + self, + self.history, + QtGui.QIcon(GuiCommon.get_resource_path("images/share_icon_toggle.png")), + QtGui.QIcon( + GuiCommon.get_resource_path("images/share_icon_toggle_selected.png") + ), + ) + + # Top bar + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addWidget(self.info_label) + top_bar_layout.addStretch() + top_bar_layout.addWidget(self.remove_all_button) + top_bar_layout.addWidget(self.toggle_history) + + # Primary action layout + self.primary_action_layout.addWidget(self.filesize_warning) + self.primary_action.hide() + self.update_primary_action() + + # Status bar, zip progress bar + self._zip_progress_bar = None + + # Main layout + self.main_layout = QtWidgets.QVBoxLayout() + self.main_layout.addLayout(top_bar_layout) + self.main_layout.addLayout(self.file_selection) + self.main_layout.addWidget(self.primary_action, stretch=1) + self.main_layout.addWidget(self.server_status) + self.main_layout.addWidget(MinimumSizeWidget(700, 0)) + + # Column layout + self.column_layout = QtWidgets.QHBoxLayout() + self.column_layout.addLayout(self.main_layout) + self.column_layout.addWidget(self.history, stretch=1) + + # Content layout + self.content_layout.addLayout(self.column_layout) + + # Always start with focus on file selection + self.file_selection.setFocus() + + def get_type(self): + """ + Returns the type of mode as a string (e.g. "share", "receive", etc.) + """ + return "share" + + def autostop_sharing_checkbox_clicked(self): + """ + Save autostop sharing setting to the tab settings + """ + self.settings.set( + "share", "autostop_sharing", self.autostop_sharing_checkbox.isChecked() + ) + + def get_stop_server_autostop_timer_text(self): + """ + Return the string to put on the stop server button, if there's an auto-stop timer + """ + return strings._("gui_share_stop_server_autostop_timer") + + def autostop_timer_finished_should_stop_server(self): + """ + The auto-stop timer expired, should we stop the server? Returns a bool + """ + # If there were no attempts to download the share, or all downloads are done, we can stop + if self.history.in_progress_count == 0 or self.web.done: + self.server_status.stop_server() + self.server_status_label.setText(strings._("close_on_autostop_timer")) + return True + # A download is probably still running - hold off on stopping the share + else: + self.server_status_label.setText( + strings._("gui_share_mode_autostop_timer_waiting") + ) + return False + + def start_server_custom(self): + """ + Starting the server. + """ + # Reset web counters + self.web.share_mode.cur_history_id = 0 + + # Hide and reset the downloads if we have previously shared + self.reset_info_counters() + + self.remove_all_button.hide() + + def start_server_step2_custom(self): + """ + Step 2 in starting the server. Zipping up files. + """ + # Add progress bar to the status bar, indicating the compressing of files. + self._zip_progress_bar = ZipProgressBar(self.common, 0) + self.filenames = self.file_selection.get_filenames() + + self._zip_progress_bar.total_files_size = ShareMode._compute_total_size( + self.filenames + ) + self.status_bar.insertWidget(0, self._zip_progress_bar) + + # prepare the files for sending in a new thread + self.compress_thread = CompressThread(self) + self.compress_thread.success.connect(self.starting_server_step3.emit) + self.compress_thread.success.connect(self.start_server_finished.emit) + self.compress_thread.error.connect(self.starting_server_error.emit) + self.server_status.server_canceled.connect(self.compress_thread.cancel) + self.compress_thread.start() + + def start_server_step3_custom(self): + """ + Step 3 in starting the server. Remove zip progress bar, and display large filesize + warning, if applicable. + """ + # Remove zip progress bar + if self._zip_progress_bar is not None: + self.status_bar.removeWidget(self._zip_progress_bar) + self._zip_progress_bar = None + + # Warn about sending large files over Tor + if self.web.share_mode.download_filesize >= 157286400: # 150mb + self.filesize_warning.setText(strings._("large_filesize")) + self.filesize_warning.show() + + def start_server_error_custom(self): + """ + Start server error. + """ + if self._zip_progress_bar is not None: + self.status_bar.removeWidget(self._zip_progress_bar) + self._zip_progress_bar = None + + def stop_server_custom(self): + """ + Stop server. + """ + # Remove the progress bar + if self._zip_progress_bar is not None: + self.status_bar.removeWidget(self._zip_progress_bar) + self._zip_progress_bar = None + + self.filesize_warning.hide() + self.history.in_progress_count = 0 + self.history.completed_count = 0 + self.history.update_in_progress() + self.file_selection.file_list.adjustSize() + + self.remove_all_button.show() + + def cancel_server_custom(self): + """ + Stop the compression thread on cancel + """ + if self.compress_thread: + self.common.log("ShareMode", "cancel_server: quitting compress thread") + self.compress_thread.quit() + + def handle_tor_broke_custom(self): + """ + Connection to Tor broke. + """ + self.primary_action.hide() + + def handle_request_started(self, event): + """ + Handle REQUEST_STARTED event. + """ + if event["data"]["use_gzip"]: + filesize = self.web.share_mode.gzip_filesize + else: + filesize = self.web.share_mode.download_filesize + + item = ShareHistoryItem(self.common, event["data"]["id"], filesize) + self.history.add(event["data"]["id"], item) + self.toggle_history.update_indicator(True) + self.history.in_progress_count += 1 + self.history.update_in_progress() + + self.system_tray.showMessage( + strings._("systray_share_started_title"), + strings._("systray_share_started_message"), + ) + + def handle_request_progress(self, event): + """ + Handle REQUEST_PROGRESS event. + """ + self.history.update(event["data"]["id"], event["data"]["bytes"]) + + # Is the download complete? + if event["data"]["bytes"] == self.web.share_mode.filesize: + self.system_tray.showMessage( + strings._("systray_share_completed_title"), + strings._("systray_share_completed_message"), + ) + + # Update completed and in progress labels + self.history.completed_count += 1 + self.history.in_progress_count -= 1 + self.history.update_completed() + self.history.update_in_progress() + + # Close on finish? + if self.settings.get("share", "autostop_sharing"): + self.server_status.stop_server() + self.status_bar.clearMessage() + self.server_status_label.setText(strings._("closing_automatically")) + else: + if self.server_status.status == self.server_status.STATUS_STOPPED: + self.history.cancel(event["data"]["id"]) + self.history.in_progress_count = 0 + self.history.update_in_progress() + + def handle_request_canceled(self, event): + """ + Handle REQUEST_CANCELED event. + """ + self.history.cancel(event["data"]["id"]) + + # Update in progress count + self.history.in_progress_count -= 1 + self.history.update_in_progress() + self.system_tray.showMessage( + strings._("systray_share_canceled_title"), + strings._("systray_share_canceled_message"), + ) + + def on_reload_settings(self): + """ + If there were some files listed for sharing, we should be ok to re-enable + the 'Start Sharing' button now. + """ + if self.server_status.file_selection.get_num_files() > 0: + self.primary_action.show() + self.info_label.show() + self.remove_all_button.show() + + def update_primary_action(self): + self.common.log("ShareMode", "update_primary_action") + + # Show or hide primary action layout + file_count = self.file_selection.file_list.count() + if file_count > 0: + self.primary_action.show() + self.info_label.show() + self.remove_all_button.show() + + # Update the file count in the info label + total_size_bytes = 0 + for index in range(self.file_selection.file_list.count()): + item = self.file_selection.file_list.item(index) + total_size_bytes += item.size_bytes + total_size_readable = self.common.human_readable_filesize(total_size_bytes) + + if file_count > 1: + self.info_label.setText( + strings._("gui_file_info").format(file_count, total_size_readable) + ) + else: + self.info_label.setText( + strings._("gui_file_info_single").format( + file_count, total_size_readable + ) + ) + + else: + self.primary_action.hide() + self.info_label.hide() + self.remove_all_button.hide() + + def reset_info_counters(self): + """ + Set the info counters back to zero. + """ + self.history.reset() + self.toggle_history.indicator_count = 0 + self.toggle_history.update_indicator() + + def delete_all(self): + """ + Delete All button clicked + """ + self.file_selection.file_list.clear() + self.file_selection.file_list.files_updated.emit() + + self.file_selection.file_list.setCurrentItem(None) + + @staticmethod + def _compute_total_size(filenames): + total_size = 0 + for filename in filenames: + if os.path.isfile(filename): + total_size += os.path.getsize(filename) + if os.path.isdir(filename): + total_size += Common.dir_size(filename) + return total_size + + +class ZipProgressBar(QtWidgets.QProgressBar): + update_processed_size_signal = QtCore.Signal(int) + + def __init__(self, common, total_files_size): + super(ZipProgressBar, self).__init__() + self.common = common + + self.setMaximumHeight(20) + self.setMinimumWidth(200) + self.setValue(0) + self.setFormat(strings._("zip_progress_bar_format")) + self.setStyleSheet(self.common.gui.css["share_zip_progess_bar"]) + + self._total_files_size = total_files_size + self._processed_size = 0 + + self.update_processed_size_signal.connect(self.update_processed_size) + + @property + def total_files_size(self): + return self._total_files_size + + @total_files_size.setter + def total_files_size(self, val): + self._total_files_size = val + + @property + def processed_size(self): + return self._processed_size + + @processed_size.setter + def processed_size(self, val): + self.update_processed_size(val) + + def update_processed_size(self, val): + self._processed_size = val + + if self.processed_size < self.total_files_size: + self.setValue(int((self.processed_size * 100) / self.total_files_size)) + elif self.total_files_size != 0: + self.setValue(100) + else: + self.setValue(0) diff --git a/desktop/onionshare/tab/mode/share_mode/threads.py b/desktop/onionshare/tab/mode/share_mode/threads.py new file mode 100644 index 00000000..839d30ea --- /dev/null +++ b/desktop/onionshare/tab/mode/share_mode/threads.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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/>. +""" + +from PySide2 import QtCore + + +class CompressThread(QtCore.QThread): + """ + Compresses files to be shared + """ + + success = QtCore.Signal() + error = QtCore.Signal(str) + + def __init__(self, mode): + super(CompressThread, self).__init__() + self.mode = mode + self.mode.common.log("CompressThread", "__init__") + + # prepare files to share + def set_processed_size(self, x): + if self.mode._zip_progress_bar is not None: + self.mode._zip_progress_bar.update_processed_size_signal.emit(x) + + def run(self): + self.mode.common.log("CompressThread", "run") + + try: + self.mode.web.share_mode.set_file_info( + self.mode.filenames, processed_size_callback=self.set_processed_size + ) + self.success.emit() + except OSError as e: + self.error.emit(e.strerror) + + def cancel(self): + self.mode.common.log("CompressThread", "cancel") + + # Let the Web and ZipWriter objects know that we're canceling compression early + self.mode.web.cancel_compression = True + try: + self.mode.web.zip_writer.cancel_compression = True + except AttributeError: + # we never made it as far as creating a ZipWriter object + pass diff --git a/desktop/onionshare/tab/mode/website_mode/__init__.py b/desktop/onionshare/tab/mode/website_mode/__init__.py new file mode 100644 index 00000000..0acbc1a2 --- /dev/null +++ b/desktop/onionshare/tab/mode/website_mode/__init__.py @@ -0,0 +1,388 @@ +# -*- coding: utf-8 -*- +""" +OnionShare | https://onionshare.org/ + +Copyright (C) 2014-2021 Micah Lee, et al. <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 os + +from PySide2 import QtCore, QtWidgets, QtGui + +from onionshare_cli.common import Common +from onionshare_cli.web import Web + +from .. import Mode +from ..file_selection import FileSelection +from ..history import History, ToggleHistory +from .... import strings +from ....widgets import MinimumSizeWidget +from ....gui_common import GuiCommon + + +class WebsiteMode(Mode): + """ + Parts of the main window UI for sharing files. + """ + + success = QtCore.Signal() + error = QtCore.Signal(str) + + def init(self): + """ + Custom initialization for ReceiveMode. + """ + # Create the Web object + self.web = Web(self.common, True, self.settings, "website") + + # Settings + # Disable CSP option + self.disable_csp_checkbox = QtWidgets.QCheckBox() + self.disable_csp_checkbox.clicked.connect(self.disable_csp_checkbox_clicked) + self.disable_csp_checkbox.setText( + strings._("mode_settings_website_disable_csp_checkbox") + ) + if self.settings.get("website", "disable_csp"): + self.disable_csp_checkbox.setCheckState(QtCore.Qt.Checked) + else: + self.disable_csp_checkbox.setCheckState(QtCore.Qt.Unchecked) + + self.mode_settings_widget.mode_specific_layout.addWidget( + self.disable_csp_checkbox + ) + + # Custom CSP option + self.custom_csp_checkbox = QtWidgets.QCheckBox() + self.custom_csp_checkbox.clicked.connect(self.custom_csp_checkbox_clicked) + self.custom_csp_checkbox.setText(strings._("mode_settings_website_custom_csp_checkbox")) + if self.settings.get("website", "custom_csp") and not self.settings.get("website", "disable_csp"): + self.custom_csp_checkbox.setCheckState(QtCore.Qt.Checked) + else: + self.custom_csp_checkbox.setCheckState(QtCore.Qt.Unchecked) + self.custom_csp = QtWidgets.QLineEdit() + self.custom_csp.setPlaceholderText( + "default-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'; img-src 'self' data:;" + ) + self.custom_csp.editingFinished.connect(self.custom_csp_editing_finished) + + custom_csp_layout = QtWidgets.QHBoxLayout() + custom_csp_layout.setContentsMargins(0, 0, 0, 0) + custom_csp_layout.addWidget(self.custom_csp_checkbox) + custom_csp_layout.addWidget(self.custom_csp) + self.mode_settings_widget.mode_specific_layout.addLayout(custom_csp_layout) + + # File selection + self.file_selection = FileSelection( + self.common, + "images/{}_mode_website.png".format(self.common.gui.color_mode), + strings._("gui_new_tab_website_button"), + self, + ) + if self.filenames: + for filename in self.filenames: + self.file_selection.file_list.add_file(filename) + + # Set title placeholder + self.mode_settings_widget.title_lineedit.setPlaceholderText( + strings._("gui_tab_name_website") + ) + + # Server status + self.server_status.set_mode("website", self.file_selection) + self.server_status.server_started.connect(self.file_selection.server_started) + self.server_status.server_stopped.connect(self.file_selection.server_stopped) + self.server_status.server_stopped.connect(self.update_primary_action) + self.server_status.server_canceled.connect(self.file_selection.server_stopped) + self.server_status.server_canceled.connect(self.update_primary_action) + self.file_selection.file_list.files_updated.connect(self.server_status.update) + self.file_selection.file_list.files_updated.connect(self.update_primary_action) + # Tell server_status about web, then update + self.server_status.web = self.web + self.server_status.update() + + # Filesize warning + self.filesize_warning = QtWidgets.QLabel() + self.filesize_warning.setWordWrap(True) + self.filesize_warning.setStyleSheet( + self.common.gui.css["share_filesize_warning"] + ) + self.filesize_warning.hide() + + # Download history + self.history = History( + self.common, + QtGui.QPixmap.fromImage( + QtGui.QImage( + GuiCommon.get_resource_path("images/share_icon_transparent.png") + ) + ), + strings._("gui_website_mode_no_files"), + strings._("gui_all_modes_history"), + "website", + ) + self.history.in_progress_label.hide() + self.history.completed_label.hide() + self.history.hide() + + # Info label + self.info_label = QtWidgets.QLabel() + self.info_label.hide() + + # Delete all files button + self.remove_all_button = QtWidgets.QPushButton( + strings._("gui_file_selection_remove_all") + ) + self.remove_all_button.setFlat(True) + self.remove_all_button.setStyleSheet( + self.common.gui.css["share_delete_all_files_button"] + ) + self.remove_all_button.clicked.connect(self.delete_all) + self.remove_all_button.hide() + + # Toggle history + self.toggle_history = ToggleHistory( + self.common, + self, + self.history, + QtGui.QIcon(GuiCommon.get_resource_path("images/share_icon_toggle.png")), + QtGui.QIcon( + GuiCommon.get_resource_path("images/share_icon_toggle_selected.png") + ), + ) + + # Top bar + top_bar_layout = QtWidgets.QHBoxLayout() + top_bar_layout.addWidget(self.info_label) + top_bar_layout.addStretch() + top_bar_layout.addWidget(self.remove_all_button) + top_bar_layout.addWidget(self.toggle_history) + + # Primary action layout + self.primary_action_layout.addWidget(self.filesize_warning) + self.primary_action.hide() + self.update_primary_action() + + # Main layout + self.main_layout = QtWidgets.QVBoxLayout() + self.main_layout.addLayout(top_bar_layout) + self.main_layout.addLayout(self.file_selection) + self.main_layout.addWidget(self.primary_action, stretch=1) + self.main_layout.addWidget(self.server_status) + self.main_layout.addWidget(MinimumSizeWidget(700, 0)) + + # Column layout + self.column_layout = QtWidgets.QHBoxLayout() + self.column_layout.addLayout(self.main_layout) + self.column_layout.addWidget(self.history, stretch=1) + + # Content layout + self.content_layout.addLayout(self.column_layout) + + # Always start with focus on file selection + self.file_selection.setFocus() + + def get_type(self): + """ + Returns the type of mode as a string (e.g. "share", "receive", etc.) + """ + return "website" + + def disable_csp_checkbox_clicked(self): + """ + Save disable CSP setting to the tab settings. Uncheck 'custom CSP' + setting if disabling CSP altogether. + """ + self.settings.set( + "website", "disable_csp", self.disable_csp_checkbox.isChecked() + ) + if self.disable_csp_checkbox.isChecked(): + self.custom_csp_checkbox.setCheckState(QtCore.Qt.Unchecked) + self.custom_csp_checkbox.setEnabled(False) + else: + self.custom_csp_checkbox.setEnabled(True) + + def custom_csp_checkbox_clicked(self): + """ + Uncheck 'disable CSP' setting if custom CSP is used. + """ + if self.custom_csp_checkbox.isChecked(): + self.disable_csp_checkbox.setCheckState(QtCore.Qt.Unchecked) + self.disable_csp_checkbox.setEnabled(False) + self.settings.set( + "website", "custom_csp", self.custom_csp + ) + else: + self.disable_csp_checkbox.setEnabled(True) + self.custom_csp.setText("") + self.settings.set( + "website", "custom_csp", None + ) + + def custom_csp_editing_finished(self): + if self.custom_csp.text().strip() == "": + self.custom_csp.setText("") + self.settings.set("website", "custom_csp", None) + else: + custom_csp = self.custom_csp.text() + self.settings.set("website", "custom_csp", custom_csp) + + def get_stop_server_autostop_timer_text(self): + """ + Return the string to put on the stop server button, if there's an auto-stop timer + """ + return strings._("gui_share_stop_server_autostop_timer") + + def autostop_timer_finished_should_stop_server(self): + """ + The auto-stop timer expired, should we stop the server? Returns a bool + """ + + self.server_status.stop_server() + self.server_status_label.setText(strings._("close_on_autostop_timer")) + return True + + def start_server_custom(self): + """ + Starting the server. + """ + # Reset web counters + self.web.website_mode.visit_count = 0 + + # Hide and reset the downloads if we have previously shared + self.reset_info_counters() + + self.remove_all_button.hide() + + def start_server_step2_custom(self): + """ + Step 2 in starting the server. Zipping up files. + """ + self.filenames = [] + for index in range(self.file_selection.file_list.count()): + self.filenames.append(self.file_selection.file_list.item(index).filename) + + # Continue + self.starting_server_step3.emit() + self.start_server_finished.emit() + + def start_server_step3_custom(self): + """ + Step 3 in starting the server. Display large filesize + warning, if applicable. + """ + self.web.website_mode.set_file_info(self.filenames) + self.success.emit() + + def start_server_error_custom(self): + """ + Start server error. + """ + if self._zip_progress_bar is not None: + self.status_bar.removeWidget(self._zip_progress_bar) + self._zip_progress_bar = None + + def stop_server_custom(self): + """ + Stop server. + """ + + self.filesize_warning.hide() + self.history.completed_count = 0 + self.file_selection.file_list.adjustSize() + + self.remove_all_button.show() + + def cancel_server_custom(self): + """ + Log that the server has been cancelled + """ + self.common.log("WebsiteMode", "cancel_server") + + def handle_tor_broke_custom(self): + """ + Connection to Tor broke. + """ + self.primary_action.hide() + + def on_reload_settings(self): + """ + If there were some files listed for sharing, we should be ok to re-enable + the 'Start Sharing' button now. + """ + if self.server_status.file_selection.get_num_files() > 0: + self.primary_action.show() + self.info_label.show() + self.remove_all_button.show() + + def update_primary_action(self): + self.common.log("WebsiteMode", "update_primary_action") + + # Show or hide primary action layout + file_count = self.file_selection.file_list.count() + if file_count > 0: + self.primary_action.show() + self.info_label.show() + self.remove_all_button.show() + + # Update the file count in the info label + total_size_bytes = 0 + for index in range(self.file_selection.file_list.count()): + item = self.file_selection.file_list.item(index) + total_size_bytes += item.size_bytes + total_size_readable = self.common.human_readable_filesize(total_size_bytes) + + if file_count > 1: + self.info_label.setText( + strings._("gui_file_info").format(file_count, total_size_readable) + ) + else: + self.info_label.setText( + strings._("gui_file_info_single").format( + file_count, total_size_readable + ) + ) + + else: + self.primary_action.hide() + self.info_label.hide() + self.remove_all_button.hide() + + def reset_info_counters(self): + """ + Set the info counters back to zero. + """ + self.history.reset() + self.toggle_history.indicator_count = 0 + self.toggle_history.update_indicator() + + def delete_all(self): + """ + Delete All button clicked + """ + self.file_selection.file_list.clear() + self.file_selection.file_list.files_updated.emit() + + self.file_selection.file_list.setCurrentItem(None) + + @staticmethod + def _compute_total_size(filenames): + total_size = 0 + for filename in filenames: + if os.path.isfile(filename): + total_size += os.path.getsize(filename) + if os.path.isdir(filename): + total_size += Common.dir_size(filename) + return total_size |