Adds Download Mode, for downloading from another OnionShare

This commit is contained in:
Miguel Jacq 2025-03-04 12:01:38 +11:00
parent 73f153af3b
commit f7adcda215
No known key found for this signature in database
GPG Key ID: 59B3F0C24135C6A9
11 changed files with 1128 additions and 41 deletions

View File

@ -43,7 +43,7 @@ class ModeSettings:
"persistent": {
"mode": None,
"enabled": False,
"autostart_on_launch": False
"autostart_on_launch": False,
},
"general": {
"title": None,
@ -67,8 +67,9 @@ class ModeSettings:
"disable_csp": False,
"custom_csp": None,
"log_filenames": False,
"filenames": []
"filenames": [],
},
"download": {"data_dir": self.build_default_receive_data_dir(), "poll": 0},
"chat": {},
}
self._settings = {}

View File

@ -190,6 +190,23 @@ class Onion(object):
s = key_b32.decode("utf-8")
return s
def private_key_to_b64(self, encoded_key):
"""
Converts a base32 encoded string back to a base64 encoded string.
This is used for adding a Private Key to an onion when operating in
client mode (such as when downloading from another OnionShare)
"""
# Decode the base32 string into bytes
encoded_key_b32 = encoded_key.encode("utf-8")
decoded_bytes = base64.b32decode(encoded_key_b32 + b"====")
# Convert the decoded bytes to base64
base64_key = base64.b64encode(decoded_bytes)
# Convert to string and return
return base64_key.decode("utf-8")
def connect(
self,
custom_settings=None,
@ -417,7 +434,11 @@ class Onion(object):
return_code = self.tor_proc.poll()
if return_code != None:
self.common.log("Onion", "connect", f"tor process has terminated early: {return_code}")
self.common.log(
"Onion",
"connect",
f"tor process has terminated early: {return_code}",
)
# Connect to the controller
self.common.log("Onion", "connect", "authenticating to tor controller")
@ -679,6 +700,31 @@ class Onion(object):
else:
return False
def add_onion_client_auth(self, service_id, private_key, key_type="x25519"):
"""
Implemented from scratch here because it never made it into Stem maint branch
"""
if self.supports_v3_onions and self.supports_ephemeral:
private_key64 = self.private_key_to_b64(private_key)
request = f"ONION_CLIENT_AUTH_ADD {service_id} {key_type}:{private_key64}"
response = self.c.msg(request)
self.common.log("Onion", "add_onion_client_auth", response)
return response
else:
raise TorTooOldEphemeral()
def remove_onion_client_auth(self, service_id):
"""
Implemented from scratch here because it never made it into Stem maint branch
"""
if self.supports_v3_onions and self.supports_ephemeral:
request = f"ONION_CLIENT_AUTH_REMOVE {service_id}"
response = self.c.msg(request)
self.common.log("Onion", "remove_onion_client_auth", response)
return response
else:
raise TorTooOldEphemeral()
def start_onion_service(self, mode, mode_settings, port, await_publication):
"""
Start a onion service on port 80, pointing to the given port, and

View File

@ -43,6 +43,7 @@ from onionshare_cli.onion import (
from onionshare_cli.meek import Meek
from onionshare_cli.web.web import WaitressException
class GuiCommon:
"""
The shared code for all of the OnionShare GUI.
@ -52,6 +53,7 @@ class GuiCommon:
MODE_RECEIVE = "receive"
MODE_WEBSITE = "website"
MODE_CHAT = "chat"
MODE_DOWNLOAD = "download"
def __init__(self, common, qtapp, local_only):
self.common = common
@ -388,7 +390,7 @@ class GuiCommon:
color: """
+ title_color
+ """;
font-size: 25px;
font-size: 16px;
}
""",
# Share mode and child widget styles
@ -485,6 +487,41 @@ class GuiCommon:
""",
}
def wrap_text(self, item, text):
"""
Helper function to insert 'fake' line breaks in long strings
(such as file names or onion addresses) so that we may then
use the resulting string in a label with setWordWrap(True)
to properly wrap it.
"""
# Get the font metrics of the label to calculate line breaks
fm = QtGui.QFontMetrics(item.font())
# Max width of the label
max_width = item.maximumWidth()
# Wrap the text by inserting line breaks
lines = []
current_line = ""
for char in text:
# Add the character to the current line
current_line += char
# If the line exceeds the maximum width, start a new line
if fm.horizontalAdvance(current_line) > max_width:
lines.append(
current_line[:-1]
) # Remove the last character and start a new line
current_line = char # Start new line with the current character
# Add the last line
if current_line:
lines.append(current_line)
# Join all lines with newlines
return "\n".join(lines)
def get_tor_paths(self):
if self.common.platform == "Linux":
base_path = self.get_resource_path("tor")
@ -595,6 +632,7 @@ class GuiCommon:
if type(e) is WaitressException:
return strings._("waitress_web_server_error")
class ToggleCheckbox(QtWidgets.QCheckBox):
def __init__(self, text):
super(ToggleCheckbox, self).__init__(text)

View File

@ -18,6 +18,8 @@
"gui_share_stop_server_autostop_timer": "Stop Sharing ({})",
"gui_chat_start_server": "Start chat server",
"gui_chat_stop_server": "Stop chat server",
"gui_download_start_server": "Start downloading",
"gui_download_stop_server": "Stop downloading",
"gui_stop_server_autostop_timer_tooltip": "Auto-stop timer ends at {}",
"gui_start_server_autostart_timer_tooltip": "Auto-start timer ends at {}",
"gui_receive_start_server": "Start Receive Mode",
@ -144,6 +146,8 @@
"gui_status_indicator_chat_working": "Starting…",
"gui_status_indicator_chat_scheduled": "Scheduled…",
"gui_status_indicator_chat_started": "Chatting",
"gui_status_indicator_download_stopped": "Stopped",
"gui_status_indicator_download_working": "Downloading…",
"gui_file_info": "{} files, {}",
"gui_file_info_single": "{} file, {}",
"history_in_progress_tooltip": "{} in progress",
@ -152,6 +156,9 @@
"error_cannot_create_data_dir": "Could not create OnionShare data folder: {}",
"gui_receive_mode_warning": "Receive mode lets people upload files to your computer.<br><br><b>Some files can potentially take control of your computer if you open them. Only open things from people you trust, or if you know what you are doing.</b>",
"gui_chat_mode_explainer": "Chat mode lets you chat interactively with others, in Tor Browser.<br><br><b>Chat history is not stored in OnionShare. The chat history will disappear when you close Tor Browser.</b>",
"gui_download_mode_explainer": "Has someone else used OnionShare to send you files?<br><br>You can enter the onion address here to download it, or use Tor Browser.",
"gui_download_mode_in_progress": "You are downloading from {}",
"gui_download_mode_in_progress_polling": "You are downloading from {}, every {} minutes",
"gui_open_folder_error": "Could not open the folder with xdg-open. The file is here: {}",
"gui_settings_language_label": "Language",
"gui_settings_theme_label": "Theme",
@ -192,20 +199,24 @@
"gui_new_tab_receive_button": "Receive Files",
"gui_new_tab_website_button": "Host a Website",
"gui_new_tab_chat_button": "Chat Anonymously",
"gui_new_tab_download_button": "Download from another OnionShare",
"gui_main_page_share_button": "Start Sharing",
"gui_main_page_receive_button": "Start Receiving",
"gui_main_page_website_button": "Start Hosting",
"gui_main_page_chat_button": "Start Chatting",
"gui_main_page_download_button": "Start Downloading",
"gui_tab_name_share": "Share",
"gui_tab_name_receive": "Receive",
"gui_tab_name_website": "Website",
"gui_tab_name_chat": "Chat",
"gui_tab_name_download": "Download",
"gui_close_tab_warning_title": "Close tab?",
"gui_close_tab_warning_persistent_description": "Close persistent tab and lose the onion address it is using?",
"gui_close_tab_warning_share_description": "Close tab that is sending files?",
"gui_close_tab_warning_receive_description": "Close tab that is receiving files?",
"gui_close_tab_warning_chat_description": "Close tab that is hosting a chat server?",
"gui_close_tab_warning_website_description": "Close tab that is hosting a website?",
"gui_close_tab_warning_download_description": "Close tab that is downloading a share?",
"gui_close_tab_warning_close": "Ok",
"gui_close_tab_warning_cancel": "Cancel",
"gui_quit_warning_title": "Quit OnionShare?",
@ -228,6 +239,11 @@
"mode_settings_receive_webhook_url_checkbox": "Use notification webhook",
"mode_settings_website_disable_csp_checkbox": "Don't send default Content Security Policy header (allows your website to use third-party resources)",
"mode_settings_website_custom_csp_checkbox": "Send a custom Content Security Policy header",
"mode_settings_download_onionshare_url_label": "Enter the OnionShare URL that you were sent:",
"mode_settings_download_onionshare_uses_private_key": "Were you also sent a Private Key?",
"mode_settings_download_onionshare_private_key": "Enter the private key here",
"mode_settings_download_poll_label": "Poll the OnionShare URL to download the share periodically (in minutes)",
"mode_settings_download_poll_warning_label": "<b>Note: polling will overwrite the same downloaded file each time.</b>",
"gui_all_modes_transfer_finished_range": "Transferred {} - {}",
"gui_all_modes_transfer_finished": "Transferred {}",
"gui_all_modes_transfer_canceled_range": "Canceled {} - {}",
@ -246,6 +262,7 @@
"gui_rendezvous_cleanup_quit_early": "Quit Early",
"error_port_not_available": "OnionShare port not available",
"history_receive_read_message_button": "Read Message",
"history_downloads_error": "There was an error downloading: {}",
"error_tor_protocol_error": "There was an error with Tor: {}",
"error_generic": "There was an unexpected error with OnionShare:\n{}",
"moat_contact_label": "Contacting BridgeDB…",

View File

@ -60,7 +60,8 @@ class Mode(QtWidgets.QWidget):
self.filenames = tab.filenames
# The web object gets created in init()
# The web object gets created in init() for modes that use a server
self.is_server = True
self.web = None
# Threads start out as None
@ -438,7 +439,8 @@ class Mode(QtWidgets.QWidget):
except Exception:
# Probably we had no port to begin with (Onion service didn't start)
pass
self.web.cleanup()
if self.is_server:
self.web.cleanup()
self.stop_server_custom()
@ -568,7 +570,9 @@ class Mode(QtWidgets.QWidget):
self.primary_action.show()
if not self.tab.timer.isActive():
self.tab.timer.start(500)
if self.settings.get("persistent", "enabled") and self.settings.get("persistent", "autostart_on_launch"):
if self.settings.get("persistent", "enabled") and self.settings.get(
"persistent", "autostart_on_launch"
):
self.server_status.start_server()
def tor_connection_stopped(self):

View File

@ -0,0 +1,500 @@
# -*- coding: utf-8 -*-
"""
OnionShare | https://onionshare.org/
Copyright (C) 2014-2022 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
import re
import requests
from PySide6 import QtCore, QtWidgets, QtGui
from onionshare_cli.common import Common
from onionshare_cli.web import Web
from urllib.parse import urlparse
from .. import Mode
from ..history import History, ToggleHistory, DownloadHistoryItem
from .... import strings
from ....threads import DownloadThread
from ....widgets import MinimumSizeWidget
from ....gui_common import GuiCommon
class DownloadMode(Mode):
"""
Parts of the main window UI for downloading a share from another OnionShare.
"""
success = QtCore.Signal()
error = QtCore.Signal(str)
def init(self):
"""
Custom initialization for DownloadMode.
"""
self.id = 0
# Download mode is client-only (no web server)
self.is_server = False
# Polling sets this to true and prevents the mode from 'stopping'
self.is_polling = False
# Download (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(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)
# Header
header_label = QtWidgets.QLabel(strings._("gui_new_tab_download_button"))
header_label.setWordWrap(True)
header_label.setStyleSheet(self.common.gui.css["mode_header_label"])
# Download mode explainer
self.download_mode_explainer = QtWidgets.QLabel(
strings._("gui_download_mode_explainer")
)
self.download_mode_explainer.setMinimumHeight(80)
self.download_mode_explainer.setWordWrap(True)
# OnionShare URL
self.onionshare_url_label = QtWidgets.QLabel(
strings._("mode_settings_download_onionshare_url_label")
)
self.onionshare_url = QtWidgets.QLineEdit()
self.onionshare_url.setStyleSheet(
"QLineEdit { color: black; } QLineEdit::placeholder { color: gray; }"
)
self.onionshare_url.setPlaceholderText(
"http://lldan5gahapx5k7iafb3s4ikijc4ni7gx5iywdflkba5y2ezyg6sjgyd.onion"
)
# Does the OnionShare URL use Client Auth?
self.onionshare_uses_private_key_checkbox = QtWidgets.QCheckBox()
self.onionshare_uses_private_key_checkbox.setText(
strings._("mode_settings_download_onionshare_uses_private_key")
)
self.onionshare_uses_private_key_checkbox.clicked.connect(
self.onionshare_uses_private_key_checkbox_checked
)
self.onionshare_private_key = QtWidgets.QLineEdit()
self.onionshare_private_key.setPlaceholderText(
strings._("mode_settings_download_onionshare_private_key")
)
self.onionshare_private_key.setStyleSheet(
"QLineEdit { color: black; } QLineEdit::placeholder { color: gray; }"
)
self.onionshare_private_key.hide()
onionshare_url_layout = QtWidgets.QVBoxLayout()
onionshare_url_layout.setContentsMargins(0, 0, 0, 0)
onionshare_url_layout.addWidget(self.onionshare_url_label)
onionshare_url_layout.addWidget(self.onionshare_url)
onionshare_url_layout.addWidget(self.onionshare_uses_private_key_checkbox)
onionshare_url_layout.addWidget(self.onionshare_private_key)
self.mode_settings_widget.mode_specific_layout.addLayout(onionshare_url_layout)
# Polling option
self.poll_checkbox = QtWidgets.QCheckBox()
self.poll_checkbox.setText(strings._("mode_settings_download_poll_label"))
self.poll_checkbox.clicked.connect(self.poll_checkbox_checked)
self.poll = QtWidgets.QSpinBox()
self.poll.setRange(0, 525600)
self.poll.setValue(0) # Set initial value to 0
self.poll.setSingleStep(1) # increment in minutes
self.poll.valueChanged.connect(self.poll_checkbox_checked)
self.poll_warning_label = QtWidgets.QLabel(
strings._("mode_settings_download_poll_warning_label")
)
self.poll_warning_label.hide()
poll_warning_layout = QtWidgets.QHBoxLayout()
poll_warning_layout.addWidget(self.poll_warning_label)
poll_settings_layout = QtWidgets.QHBoxLayout()
poll_settings_layout.addWidget(self.poll_checkbox)
poll_settings_layout.addWidget(self.poll)
poll_layout = QtWidgets.QVBoxLayout()
poll_layout.addLayout(poll_settings_layout)
poll_layout.addLayout(poll_warning_layout)
self.mode_settings_widget.mode_specific_layout.addLayout(poll_layout)
# 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("download", "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)
# These settings make no sense for Download mode
self.mode_settings_widget.persistent_checkbox.hide()
self.mode_settings_widget.persistent_autostart_on_launch_checkbox.hide()
self.mode_settings_widget.public_checkbox.hide()
self.mode_settings_widget.advanced_widget.hide()
self.mode_settings_widget.toggle_advanced_button.hide()
# Server status
self.server_status.set_mode("download")
self.server_status.server_stopped.connect(self.update_primary_action)
self.server_status.update()
# 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"),
"download",
)
self.history.requests_label.hide()
self.history.hide()
# Info label
self.info_label = QtWidgets.QLabel()
self.info_label.hide()
# Toggle history
self.toggle_history = ToggleHistory(
self.common,
self,
self.history,
QtGui.QIcon(
GuiCommon.get_resource_path(
f"images/{self.common.gui.color_mode}_history_icon_toggle.svg"
)
),
QtGui.QIcon(
GuiCommon.get_resource_path(
f"images/{self.common.gui.color_mode}_history_icon_toggle_selected.svg"
)
),
)
# 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))
top_bar_layout.addWidget(self.info_label)
top_bar_layout.addStretch()
top_bar_layout.addWidget(self.toggle_history)
# Primary action layout
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.addWidget(header_label)
self.main_layout.addWidget(self.download_mode_explainer)
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)
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 "download"
def onionshare_uses_private_key_checkbox_checked(self):
"""
If the user checked the box saying a Private Key was sent, show the private key lineEdit field.
"""
if self.onionshare_uses_private_key_checkbox.isChecked():
self.onionshare_private_key.show()
else:
self.onionshare_private_key.hide()
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(
"DownloadMode",
"data_dir_button_clicked",
f"selected dir: {selected_dir}",
)
self.data_dir_lineedit.setText(selected_dir)
self.settings.set("download", "data_dir", selected_dir)
def poll_checkbox_checked(self):
"""
If the user checks the poll option or changes the value of the poll interval
when it is checkced, set the poll interval in settings.
"""
if self.poll_checkbox.isChecked():
self.common.log(
"DownloadMode",
"poll_checkbox_checked",
f"Set poll to : {self.poll.value()}",
)
self.settings.set("download", "poll", self.poll.value())
self.poll_warning_label.show()
else:
self.settings.set("download", "poll", 0)
self.poll_warning_label.hide()
def extract_domain(self, url):
# First, parse the URL to get the domain
parsed_url = urlparse(url)
# Extract the domain (without scheme and path)
domain_with_tld = parsed_url.netloc.split(":")[0] # Remove port if any
# Regular expression to extract the main domain name
match = re.search(r"([a-zA-Z0-9-]+)\.[a-zA-Z]{2,}", domain_with_tld)
if match:
return match.group(1)
else:
return None # Return None if no domain is found
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.id += 1
self.set_server_active.emit(True)
# Hide and reset the downloads if we have previously shared
self.reset_info_counters()
# Clear the status bar
self.status_bar.clearMessage()
self.server_status_label.setText("")
# Hide the mode settings
self.mode_settings_widget.hide()
self.service_id = self.extract_domain(self.onionshare_url.text().strip())
self.common.log("DownloadMode", "start_server")
if not self.common.gui.local_only:
self.download_thread = DownloadThread(self)
self.download_thread.begun.connect(self.add_download_item)
self.download_thread.progress.connect(self.progress_download_item)
self.download_thread.success.connect(self.finished_download_item)
self.download_thread.error.connect(self.error_download_item)
self.download_thread.locked.connect(self.locked_download_item)
# Start the download thread
self.download_thread.start()
if (
self.settings.get("download", "poll")
and self.settings.get("download", "poll") >= 1
):
self.is_polling = True
# Set up a QTimer to trigger the thread at end of each polling interval
self.timer = QtCore.QTimer(self)
self.timer.timeout.connect(self.trigger_download_thread)
self.timer.setInterval(
self.settings.get("download", "poll") * 60 * 1000
) # Minutes to milliseconds
self.timer.start()
# Update the 'Explainer' label to explain what is happening.
if self.is_polling:
self.download_mode_explainer.setText(
strings._("gui_download_mode_in_progress_polling").format(
self.onionshare_url.text().strip(), self.poll.value()
)
)
else:
self.download_mode_explainer.setText(
strings._("gui_download_mode_in_progress").format(
self.onionshare_url.text().strip()
)
)
def trigger_download_thread(self):
if not self.download_thread._lock:
self.download_thread.start()
def start_server_step2_custom(self):
# 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("DownloadMode", "cancel_server")
def stop_server_custom(self):
"""
If any polling is taking place, stop iot
"""
if self.is_polling:
self.common.log(
"DownloadMode", "stop_server_custom", "Stopping Download polling timer"
)
self.timer.stop()
if self.download_thread:
self.common.log(
"DownloadMode", "stop_server_custom", "Stopping DownloadThread"
)
self.download_thread.quit()
self.download_thread.wait()
self.common.gui.onion.remove_onion_client_auth(self.service_id)
self.download_mode_explainer.setText(strings._("gui_download_mode_explainer"))
def handle_tor_broke_custom(self):
"""
Connection to Tor broke.
"""
self.primary_action.hide()
def update_primary_action(self):
self.common.log("DownloadMode", "update_primary_action")
# Show or hide primary action layout
self.primary_action.show()
self.info_label.show()
def add_download_item(self):
self.common.log("DownloadMode", "add_download_item", self.id)
item = DownloadHistoryItem(
self.common,
self.id,
self.onionshare_url.text().strip(),
)
self.history.add(self.id, item)
self.toggle_history.update_indicator(True)
self.history.in_progress_count += 1
self.history.update_in_progress()
def progress_download_item(self, progress):
self.common.log(
"DownloadMode", "progress_download_item", f"Progress: {progress}%"
)
self.history.update(self.id, {"action": "progress", "progress": progress})
self.history.update_in_progress()
def finished_download_item(self, file_path, file_name, file_size):
self.common.log(
"DownloadMode",
"finished_download_item",
f"File path is {file_path}, file name is {file_name}, file size is {file_size}",
)
self.history.update(
self.id,
{
"action": "finished",
"file_path": file_path,
"file_name": file_name,
"file_size": file_size,
},
)
self.history.completed_count += 1
self.history.in_progress_count -= 1
self.history.update_completed()
self.history.update_in_progress()
self.start_server_step2_custom()
if not self.is_polling:
self.stop_server()
def locked_download_item(self):
self.common.log(
"DownloadMode",
"locked_download_item",
"Thread is locked, a download must be running",
)
if self.history.in_progress_count > 0:
self.history.in_progress_count -= 1
def error_download_item(self, error):
self.common.log("DownloadMode", "error_download_item", f"{self.id}: {error}")
self.history.update(self.id, {"action": "error", "error": error})
if self.history.in_progress_count > 0:
self.history.in_progress_count -= 1
self.history.update_in_progress()
if not self.is_polling:
self.stop_server()
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()

View File

@ -192,8 +192,11 @@ class ReceiveHistoryItemFile(QtWidgets.QWidget):
self.started = datetime.now()
# Filename label
self.filename_label = QtWidgets.QLabel(self.filename)
self.filename_label_width = self.filename_label.width()
self.filename_label = QtWidgets.QLabel()
self.filename_label.setWordWrap(True)
self.filename_label.setMaximumWidth(250)
filename_text = self.common.gui.wrap_text(self.filename_label, self.filename)
self.filename_label.setText(filename_text)
# File size label
self.filesize_label = QtWidgets.QLabel()
@ -229,7 +232,8 @@ class ReceiveHistoryItemFile(QtWidgets.QWidget):
def rename(self, new_filename):
self.filename = new_filename
self.filename_label.setText(self.filename)
filename_text = self.common.gui.wrap_text(self.filename_label, self.filename)
self.filename_label.setText(filename_text)
def set_dir(self, dir):
self.dir = dir
@ -483,9 +487,14 @@ class IndividualFileHistoryItem(HistoryItem):
self.timestamp_label.setStyleSheet(
self.common.gui.css["history_individual_file_timestamp_label"]
)
self.path_label = QtWidgets.QLabel(self.path)
self.path_label = QtWidgets.QLabel()
self.path_label.setTextFormat(QtCore.Qt.PlainText)
self.path_label.setStyleSheet(self.common.gui.css["history_default_label"])
self.path_label.setWordWrap(True)
self.path_label.setMaximumWidth(250)
path_text = self.common.gui.wrap_text(self.path_label, self.path)
self.path_label.setText(path_text)
self.status_code_label = QtWidgets.QLabel()
# Progress bar
@ -788,10 +797,14 @@ class History(QtWidgets.QWidget):
Update the 'completed' widget.
"""
if self.completed_count == 0:
image = GuiCommon.get_resource_path(f"images/{self.common.gui.color_mode}_history_completed_none.svg")
image = GuiCommon.get_resource_path(
f"images/{self.common.gui.color_mode}_history_completed_none.svg"
)
else:
image = GuiCommon.get_resource_path("images/history_completed.svg")
self.completed_label.setText(f'<img src="{image}" height="10" /> {self.completed_count}')
self.completed_label.setText(
f'<img src="{image}" height="10" /> {self.completed_count}'
)
self.completed_label.setToolTip(
strings._("history_completed_tooltip").format(self.completed_count)
)
@ -801,7 +814,9 @@ class History(QtWidgets.QWidget):
Update the 'in progress' widget.
"""
if self.in_progress_count == 0:
image = GuiCommon.get_resource_path(f"images/{self.common.gui.color_mode}_history_in_progress_none.svg")
image = GuiCommon.get_resource_path(
f"images/{self.common.gui.color_mode}_history_in_progress_none.svg"
)
else:
image = GuiCommon.get_resource_path("images/history_in_progress.svg")
@ -817,11 +832,15 @@ class History(QtWidgets.QWidget):
Update the 'web requests' widget.
"""
if self.requests_count == 0:
image = GuiCommon.get_resource_path(f"images/{self.common.gui.color_mode}_history_requests_none.svg")
image = GuiCommon.get_resource_path(
f"images/{self.common.gui.color_mode}_history_requests_none.svg"
)
else:
image = GuiCommon.get_resource_path("images/history_requests.svg")
self.requests_label.setText(f'<img src="{image}" height="10" /> {self.requests_count}')
self.requests_label.setText(
f'<img src="{image}" height="10" /> {self.requests_count}'
)
self.requests_label.setToolTip(
strings._("history_requests_tooltip").format(self.requests_count)
)
@ -894,3 +913,159 @@ class ToggleHistory(QtWidgets.QPushButton):
# Reset the indicator count
self.indicator_count = 0
self.update_indicator()
class DownloadHistoryItem(HistoryItem):
def __init__(self, common, id, url, file_name=None, file_size=None):
super(DownloadHistoryItem, self).__init__()
self.common = common
self.id = id
self.url = url
self.file_name = file_name
self.file_size = file_size
self.started = datetime.now()
self.status = HistoryItem.STATUS_STARTED
self.common.log(
"DownloadHistoryItem",
"__init__",
f"id={self.id} url={self.url} file_name = {self.file_size} file_size = {self.file_size}",
)
# Label
self.label = QtWidgets.QLabel(
strings._("gui_all_modes_transfer_started").format(
self.started.strftime("%b %d, %I:%M%p")
)
)
self.label.setWordWrap(True)
# URL so we know which onion service we downloaded this share from
self.url_label = QtWidgets.QLabel()
self.url_label.setWordWrap(True)
self.url_label.setMaximumWidth(250)
self.url_text = self.common.gui.wrap_text(self.url_label, self.url)
self.url_label.setText(self.url_text)
# 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.setRange(0, 0)
self.progress_bar.setValue(0)
self.progress_bar.setStyleSheet(
self.common.gui.css["downloads_uploads_progress_bar"]
)
# This layout contains file widgets
self.files_layout = QtWidgets.QVBoxLayout()
self.files_layout.setContentsMargins(0, 0, 0, 0)
files_widget = QtWidgets.QWidget()
files_widget.setStyleSheet(self.common.gui.css["receive_file"])
files_widget.setLayout(self.files_layout)
# Layout
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.label)
layout.addWidget(self.url_label)
layout.addWidget(self.progress_bar)
layout.addWidget(files_widget)
layout.addStretch()
self.setLayout(layout)
def update(self, data):
"""
Using the progress from Web, update the progress bar and file size labels
for each file
"""
if data["action"] == "progress":
# Update the progress bar
self.progress_bar.setMaximum(100)
self.progress_bar.setValue(data["progress"])
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"])
self.file_name = data["file_name"]
self.file_path = data["file_path"]
self.file_size = self.common.human_readable_filesize(data["file_size"])
# Filename label
self.filename_label = QtWidgets.QLabel(self.file_name)
self.filename_label_width = self.filename_label.width()
# File size label
self.filesize_label = QtWidgets.QLabel(self.file_size)
self.filesize_label.setStyleSheet(self.common.gui.css["receive_file_size"])
# Folder button
image = QtGui.QImage(GuiCommon.get_resource_path("images/open_folder.svg"))
scaled_image = image.scaledToHeight(15, QtCore.Qt.SmoothTransformation)
folder_pixmap = QtGui.QPixmap.fromImage(scaled_image)
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)
# Layouts
layout = QtWidgets.QHBoxLayout()
layout.addWidget(self.filename_label)
layout.addWidget(self.filesize_label)
layout.addStretch()
layout.addWidget(self.folder_button)
file_widget = QtWidgets.QWidget()
file_widget.setLayout(layout)
self.files_layout.addWidget(file_widget)
if data["action"] == "error":
# Change the status
self.status = HistoryItem.STATUS_CANCELED
# Hide the progress bar
self.progress_bar.hide()
# Change the label
error_data_formatted = data["error"]
self.error_data_text = self.common.gui.wrap_text(self.label, data["error"])
self.label.setText(
strings._("history_downloads_error").format(self.error_data_text)
)
self.label.setStyleSheet(self.common.gui.css["history_default_label"])
def open_folder(self):
"""
Open the downloads folder, with the file selected, in a cross-platform manner
"""
self.common.log("DownloadHistoryItemFile", "open_folder")
# Linux
if self.common.platform == "Linux" or self.common.platform == "BSD":
try:
# If nautilus is available, open it
subprocess.Popen(["xdg-open", self.file_path])
except Exception:
Alert(
self.common,
strings._("gui_open_folder_error").format(self.file_path),
)
# macOS
elif self.common.platform == "Darwin":
subprocess.call(["open", "-R", self.file_path])
# Windows
elif self.common.platform == "Windows":
subprocess.Popen(["explorer", f"/select,{self.file_path}"])

View File

@ -40,12 +40,20 @@ class ModeSettingsWidget(QtWidgets.QScrollArea):
self.mode_specific_layout = QtWidgets.QVBoxLayout()
self.persistent_autostart_on_launch_checkbox = QtWidgets.QCheckBox()
self.persistent_autostart_on_launch_checkbox.clicked.connect(self.persistent_autostart_on_launch_checkbox_clicked)
self.persistent_autostart_on_launch_checkbox.setText(strings._("mode_settings_persistent_autostart_on_launch_checkbox"))
self.persistent_autostart_on_launch_checkbox.clicked.connect(
self.persistent_autostart_on_launch_checkbox_clicked
)
self.persistent_autostart_on_launch_checkbox.setText(
strings._("mode_settings_persistent_autostart_on_launch_checkbox")
)
if self.settings.get("persistent", "autostart_on_launch"):
self.persistent_autostart_on_launch_checkbox.setCheckState(QtCore.Qt.Checked)
self.persistent_autostart_on_launch_checkbox.setCheckState(
QtCore.Qt.Checked
)
else:
self.persistent_autostart_on_launch_checkbox.setCheckState(QtCore.Qt.Unchecked)
self.persistent_autostart_on_launch_checkbox.setCheckState(
QtCore.Qt.Unchecked
)
# Persistent
self.persistent_checkbox = QtWidgets.QCheckBox()
@ -207,6 +215,10 @@ class ModeSettingsWidget(QtWidgets.QScrollArea):
self.tab.change_title.emit(
self.tab.tab_id, strings._("gui_tab_name_chat")
)
elif self.tab.mode == self.common.gui.MODE_DOWNLOAD:
self.tab.change_title.emit(
self.tab.tab_id, strings._("gui_tab_name_download")
)
elif self.tab_mode is None:
pass
else:
@ -217,7 +229,11 @@ class ModeSettingsWidget(QtWidgets.QScrollArea):
def persistent_checkbox_clicked(self):
self.settings.set("persistent", "enabled", self.persistent_checkbox.isChecked())
self.settings.set("persistent", "mode", self.tab.mode)
self.settings.set("persistent", "autostart_on_launch", self.persistent_autostart_on_launch_checkbox.isChecked())
self.settings.set(
"persistent",
"autostart_on_launch",
self.persistent_autostart_on_launch_checkbox.isChecked(),
)
self.change_persistent.emit(
self.tab.tab_id, self.persistent_checkbox.isChecked()
)
@ -230,7 +246,11 @@ class ModeSettingsWidget(QtWidgets.QScrollArea):
self.persistent_autostart_on_launch_checkbox.show()
def persistent_autostart_on_launch_checkbox_clicked(self):
self.settings.set("persistent", "autostart_on_launch", self.persistent_autostart_on_launch_checkbox.isChecked())
self.settings.set(
"persistent",
"autostart_on_launch",
self.persistent_autostart_on_launch_checkbox.isChecked(),
)
def public_checkbox_clicked(self):
self.settings.set("general", "public", self.public_checkbox.isChecked())

View File

@ -326,7 +326,7 @@ class ServerStatus(QtWidgets.QWidget):
"""
self.common.log("ServerStatus", "update")
# Set the URL fields
if self.status == self.STATUS_STARTED:
if self.status == self.STATUS_STARTED and not self.mode == self.common.gui.MODE_DOWNLOAD:
# The backend Onion may have saved new settings, such as the private key.
# Reload the settings before saving new ones.
self.common.settings.load()
@ -379,6 +379,8 @@ class ServerStatus(QtWidgets.QWidget):
self.server_button.setText(strings._("gui_share_start_server"))
elif self.mode == self.common.gui.MODE_CHAT:
self.server_button.setText(strings._("gui_chat_start_server"))
elif self.mode == self.common.gui.MODE_DOWNLOAD:
self.server_button.setText(strings._("gui_download_start_server"))
else:
self.server_button.setText(strings._("gui_receive_start_server"))
self.server_button.setToolTip("")
@ -393,6 +395,8 @@ class ServerStatus(QtWidgets.QWidget):
self.server_button.setText(strings._("gui_share_stop_server"))
elif self.mode == self.common.gui.MODE_CHAT:
self.server_button.setText(strings._("gui_chat_stop_server"))
elif self.mode == self.common.gui.MODE_DOWNLOAD:
self.server_button.setText(strings._("gui_download_stop_server"))
else:
self.server_button.setText(strings._("gui_receive_stop_server"))
elif self.status == self.STATUS_WORKING:
@ -409,12 +413,18 @@ class ServerStatus(QtWidgets.QWidget):
)
)
else:
if self.common.platform == "Windows":
self.server_button.setText(strings._("gui_please_wait"))
else:
if self.mode == self.common.gui.MODE_DOWNLOAD:
self.server_button.setEnabled(True)
self.server_button.setText(
strings._("gui_please_wait_no_button")
strings._("gui_status_indicator_download_working")
)
else:
if self.common.platform == "Windows":
self.server_button.setText(strings._("gui_please_wait"))
else:
self.server_button.setText(
strings._("gui_please_wait_no_button")
)
else:
self.server_button.setStyleSheet(
self.common.gui.css["server_status_button_working"]

View File

@ -29,6 +29,7 @@ from .mode.share_mode import ShareMode
from .mode.receive_mode import ReceiveMode
from .mode.website_mode import WebsiteMode
from .mode.chat_mode import ChatMode
from .mode.download_mode import DownloadMode
from .server_status import ServerStatus
@ -133,11 +134,10 @@ class Tab(QtWidgets.QWidget):
)
)
)
self.image_label.setFixedSize(180, 40)
image_layout = QtWidgets.QVBoxLayout()
image_layout.addWidget(self.image_label)
image_layout.addStretch()
self.image = QtWidgets.QWidget()
self.image.setFixedSize(280, 280)
self.image.setLayout(image_layout)
# New tab buttons
@ -177,14 +177,25 @@ class Tab(QtWidgets.QWidget):
)
self.chat_button.clicked.connect(self.chat_mode_clicked)
self.download_button = NewTabButton(
self.common,
"images/{}_mode_new_tab_receive.png".format(self.common.gui.color_mode),
strings._("gui_new_tab_download_button"),
strings._("gui_main_page_download_button"),
QtCore.Qt.Key_D,
)
self.download_button.clicked.connect(self.download_mode_clicked)
new_tab_top_layout = QtWidgets.QHBoxLayout()
new_tab_top_layout.addStretch()
new_tab_top_layout.addWidget(self.image)
new_tab_top_layout.addWidget(self.share_button)
new_tab_top_layout.addWidget(self.receive_button)
new_tab_top_layout.addStretch()
new_tab_bottom_layout = QtWidgets.QHBoxLayout()
new_tab_bottom_layout.addStretch()
new_tab_bottom_layout.addWidget(self.download_button)
new_tab_bottom_layout.addWidget(self.website_button)
new_tab_bottom_layout.addWidget(self.chat_button)
new_tab_bottom_layout.addStretch()
@ -195,14 +206,8 @@ class Tab(QtWidgets.QWidget):
new_tab_layout.addLayout(new_tab_bottom_layout)
new_tab_layout.addStretch()
new_tab_img_layout = QtWidgets.QHBoxLayout()
new_tab_img_layout.addWidget(self.image)
new_tab_img_layout.addStretch(1)
new_tab_img_layout.addLayout(new_tab_layout)
new_tab_img_layout.addStretch(2)
self.new_tab = QtWidgets.QWidget()
self.new_tab.setLayout(new_tab_img_layout)
self.new_tab.setLayout(new_tab_layout)
self.new_tab.show()
# Layout
@ -253,6 +258,8 @@ class Tab(QtWidgets.QWidget):
self.website_mode_clicked()
elif mode == "chat":
self.chat_mode_clicked()
elif mode == "download":
self.download_mode_clicked()
else:
# This is a new tab
self.settings = ModeSettings(self.common)
@ -399,6 +406,38 @@ class Tab(QtWidgets.QWidget):
self.update_server_status_indicator()
self.timer.start(500)
def download_mode_clicked(self):
self.common.log("Tab", "download_mode_clicked")
self.mode = self.common.gui.MODE_DOWNLOAD
self.new_tab.hide()
self.download_mode = DownloadMode(self)
self.layout.addWidget(self.download_mode)
self.download_mode.show()
self.download_mode.init()
self.download_mode.server_status.server_started.connect(
self.update_server_status_indicator
)
self.download_mode.server_status.server_stopped.connect(
self.update_server_status_indicator
)
self.download_mode.start_server_finished.connect(
self.update_server_status_indicator
)
self.download_mode.stop_server_finished.connect(
self.update_server_status_indicator
)
self.download_mode.stop_server_finished.connect(self.stop_server_finished)
self.download_mode.start_server_finished.connect(self.clear_message)
self.download_mode.server_status.button_clicked.connect(self.clear_message)
self.change_title.emit(self.tab_id, strings._("gui_tab_name_download"))
self.update_server_status_indicator()
self.timer.start(500)
def update_server_status_indicator(self):
# Set the status image
if self.mode == self.common.gui.MODE_SHARE:
@ -477,6 +516,16 @@ class Tab(QtWidgets.QWidget):
self.set_server_status_indicator_started(
strings._("gui_status_indicator_chat_started")
)
elif self.mode == self.common.gui.MODE_DOWNLOAD:
# Download mode
if self.download_mode.server_status.status == ServerStatus.STATUS_STOPPED:
self.set_server_status_indicator_stopped(
strings._("gui_status_indicator_download_stopped")
)
elif self.download_mode.server_status.status == ServerStatus.STATUS_WORKING:
self.set_server_status_indicator_working(
strings._("gui_status_indicator_download_working")
)
def set_server_status_indicator_stopped(self, label_text):
self.change_icon.emit(
@ -537,8 +586,11 @@ class Tab(QtWidgets.QWidget):
done = False
while not done:
try:
r = mode.web.q.get(False)
events.append(r)
if mode.is_server:
r = mode.web.q.get(False)
events.append(r)
else:
done = True
except queue.Empty:
done = True
@ -632,8 +684,12 @@ class Tab(QtWidgets.QWidget):
return self.receive_mode
elif self.mode == self.common.gui.MODE_CHAT:
return self.chat_mode
else:
elif self.mode == self.common.gui.MODE_DOWNLOAD:
return self.download_mode
elif self.mode == self.common.gui.MODE_WEBSITE:
return self.website_mode
else:
return None
else:
return None
@ -655,6 +711,10 @@ class Tab(QtWidgets.QWidget):
dialog_text = strings._("gui_close_tab_warning_receive_description")
elif self.mode == self.common.gui.MODE_CHAT:
dialog_text = strings._("gui_close_tab_warning_chat_description")
elif self.mode == self.common.gui.MODE_DOWNLOAD:
dialog_text = strings._(
"gui_close_tab_warning_download_description"
)
else:
dialog_text = strings._("gui_close_tab_warning_website_description")
@ -673,7 +733,7 @@ class Tab(QtWidgets.QWidget):
def cleanup(self):
self.common.log("Tab", "cleanup", f"tab_id={self.tab_id}")
if self.get_mode():
if self.get_mode() and self.get_mode().is_server:
if self.get_mode().web_thread:
self.get_mode().web.stop(self.get_mode().app.port)
self.get_mode().web_thread.quit()

View File

@ -18,9 +18,11 @@ 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 json
import os
import requests
import time
from datetime import datetime
from PySide6 import QtCore
from onionshare_cli.onion import (
@ -127,6 +129,8 @@ class WebThread(QtCore.QThread):
self.mode.common.log("WebThread", "__init__")
def run(self):
if not self.mode.is_server:
return
self.mode.common.log("WebThread", "run")
try:
self.mode.web.start(self.mode.app.port)
@ -137,6 +141,7 @@ class WebThread(QtCore.QThread):
self.error.emit(message)
return
class AutoStartTimer(QtCore.QThread):
"""
Waits for a prescribed time before allowing a share to start
@ -276,3 +281,214 @@ class OnionCleanupThread(QtCore.QThread):
def run(self):
self.common.log("OnionCleanupThread", "run")
self.common.gui.onion.cleanup()
class DownloadThread(QtCore.QThread):
"""
Download an Onion share in a separate thread (for Download Mode)
"""
begun = QtCore.Signal()
progress = QtCore.Signal(int) # progress percentage
success = QtCore.Signal(
str, str, int
) # Emit file_path (str), file_name (str), and file_size (int)
error = QtCore.Signal(str)
locked = QtCore.Signal()
def __init__(self, mode):
super(DownloadThread, self).__init__()
self._mutex = QtCore.QMutex() # Mutex for locking
self._lock = False # To track the lock status
self.mode = mode
self.mode.common.log("DownloadThread", "__init__")
self.saved_download_mode_dir = None
def run(self):
if self._mutex.tryLock():
self._lock = True
self.mode.common.log("DownloadThread", "run", "Lock claimed")
self.begun.emit()
try:
self.mode.common.log("DownloadThread", "run")
if self.mode.common.gui.local_only:
proxies = {}
else:
# Obtain the SocksPort from Tor and set it as the proxies for Requests
(socks_address, socks_port) = (
self.mode.common.gui.onion.get_tor_socks_port()
)
proxies = {
"http": f"socks5h://{socks_address}:{socks_port}",
"https": f"socks5h://{socks_address}:{socks_port}",
}
# We only support the /download (zip) route for Share Mode right now
# (no individual file downloads)
url = f"http://{self.mode.service_id}.onion/download"
# Set up Client Auth if required
if (
self.mode.onionshare_uses_private_key_checkbox.isChecked()
and len(self.mode.onionshare_private_key.text().strip()) == 52
):
self.mode.common.log(
"DownloadThread", "run", f"Setting private key"
)
self.mode.common.gui.onion.add_onion_client_auth(
self.mode.service_id,
self.mode.onionshare_private_key.text().strip(),
)
response = requests.get(url, proxies=proxies, stream=True)
# Check if the request was successful (HTTP status code 200)
if response.status_code == 200:
# Extract the file name from the 'Content-Disposition' header, if present
file_name = self.get_filename_from_content_disposition(
response.headers
)
self.mode.common.log(
"DownloadThread",
"run",
f"file_name is {file_name}, now we will try and write it",
)
# Get the file size from the 'Content-Length' header
file_size = int(response.headers.get("Content-Length", 0))
# Save the share
file_path = self.save_share(file_name, response, file_size)
self.mode.common.log(
"DownloadThread",
"run",
f"file_path is {file_path}, we saved it",
)
# Emit the file path, file name, and file size to DownloadMode's add_download_item()
self.success.emit(file_path, file_name, file_size)
else:
raise Exception(
f"Failed to download file. Status code: {response.status_code}"
)
except Exception as e:
self.mode.common.log(
"DownloadThread", "run", f"Error occurred in requests: {e}"
)
self.error.emit(str(e))
return
finally:
# Always release the mutex, even if an exception occurred
self._mutex.unlock()
self._lock = False
else:
self.mode.common.log(
"DownloadThread",
"run",
"Lock was in place, maybe the last download was still occurring?",
)
self.locked.emit()
def get_filename_from_content_disposition(self, headers):
content_disposition = headers.get("Content-Disposition", "")
# Extract filename directly from Content-Disposition
if "filename" in content_disposition:
# Extract the filename (it could contain the UTF-8 encoded form as well)
filename = (
content_disposition.split("filename=")[1].split(";")[0].strip('"')
)
elif "filename*" in content_disposition:
# Extract and decode filename* if filename* is used
filename = unquote(
content_disposition.split("filename*=UTF-8''")[1].strip('"')
)
else:
# Fallback default filename
filename = "downloaded_file.zip"
return filename
def save_share(self, file_name, response, file_size):
"""
Write the downloaded share to disk. This is similar to ReceiveMode,
in that we try to create a unique timestamp-based directory to save
to.
The exception is if we are in polling mode, in which case, we want
to continually overwrite the existing download with a new version,
to keep it up to date.
"""
if self.mode.is_polling and self.mode.history.completed_count > 0:
download_mode_dir = self.saved_download_mode_dir
else:
now = datetime.now()
date_dir = now.strftime("%Y-%m-%d")
time_dir = now.strftime("%H%M%S%f")
download_mode_dir = os.path.join(
self.mode.settings.get("download", "data_dir"), date_dir, time_dir
)
# Create that directory, which shouldn't exist yet
try:
os.makedirs(download_mode_dir, 0o700, exist_ok=False)
except OSError:
# If this directory already exists, maybe someone else is downloading files at
# the same second in another tab, so use a different name in that case
if os.path.exists(download_mode_dir):
# Keep going until we find a directory name that's available
i = 1
while True:
new_download_mode_dir = f"{download_mode_dir}-{i}"
try:
os.makedirs(new_download_mode_dir, 0o700, exist_ok=False)
download_mode_dir = new_download_mode_dir
break
except OSError:
pass
i += 1
# Failsafe
if i == 100:
self.mode.common.log(
"DownloadThread",
"save_share",
"Error finding available download directory",
)
raise Exception(
"Error finding available download directory"
)
except PermissionError:
raise Exception("Permission denied creating download directory")
# Save this successful download dir if we are in polling mode, so we can
# use it next time.
if self.mode.is_polling and self.mode.history.completed_count == 0:
self.saved_download_mode_dir = download_mode_dir
# Build file path for saving
file_path = os.path.join(download_mode_dir, file_name)
self.mode.common.log(
"DownloadThread",
"save_share",
f"File path: {file_path}",
)
# Save the file and report the progress back to the UI
with open(file_path, "wb") as file:
downloaded = 0
for chunk in response.iter_content(chunk_size=1024): # 1KB chunks
if chunk:
downloaded += len(chunk) # Update the downloaded bytes
# Send progress via signal to the main thread
progress = int((downloaded / file_size) * 100)
self.progress.emit(progress)
file.write(chunk)
return file_path