adding browser extension and some QoL

This commit is contained in:
2026-08-12 00:14:47 +02:00
parent 0ca65ea473
commit d5f092883a
14 changed files with 1708 additions and 29 deletions

378
gui.py
View File

@@ -13,6 +13,17 @@ import re
import shutil
import socket
import ssl
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from PySide6.QtCore import Qt, QThread, QObject, Signal, QUrl, QFile, QIODevice, QTimer, QMimeData, QProcess
from PySide6.QtGui import QIcon, QAction, QPixmap, QPainter, QColor, QFont, QPen, QImage, QDesktopServices, QDrag, QClipboard
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QHttpMultiPart, QHttpPart, QNetworkReply, QLocalServer, QLocalSocket
from PySide6.QtWidgets import (
QApplication, QSystemTrayIcon, QMenu, QDialog, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QGroupBox,
QFormLayout, QMessageBox, QFileDialog, QSlider, QScrollArea, QFrame, QGridLayout, QWidget, QSizePolicy, QInputDialog
)
def normalize_file_path(path_str: str) -> str:
if not path_str:
@@ -26,6 +37,90 @@ def normalize_file_path(path_str: str) -> str:
path_str = urllib.parse.unquote(path_str[7:])
return os.path.abspath(path_str)
def parse_upload_target(arg: str) -> tuple[str, bool]:
if not arg:
return ("", False)
arg = arg.strip().strip("'\"")
if arg.startswith("f0ckm://") or arg.startswith("f0ckm-uploader://"):
parsed = urllib.parse.urlparse(arg)
query_params = urllib.parse.parse_qs(parsed.query)
target_url = ""
if "url" in query_params and query_params["url"]:
target_url = query_params["url"][0]
else:
raw_path = parsed.netloc + parsed.path
if parsed.query:
raw_path += "?" + parsed.query
raw_path = raw_path.lstrip("/")
if raw_path.startswith("upload/"):
raw_path = raw_path[7:]
elif raw_path.startswith("upload?"):
raw_path = raw_path[7:]
target_url = raw_path
target_url = urllib.parse.unquote(target_url)
if target_url.startswith("http://") or target_url.startswith("https://"):
print(f"[f0ckm-gui] [PARSE] Protocol URL parsed: '{arg}' -> target='{target_url}' (is_url=True)", flush=True)
return (target_url, True)
if arg.startswith("http://") or arg.startswith("https://"):
print(f"[f0ckm-gui] [PARSE] Direct URL parsed: '{arg}' (is_url=True)", flush=True)
return (arg, True)
norm = normalize_file_path(arg)
print(f"[f0ckm-gui] [PARSE] File path parsed: '{arg}' -> norm='{norm}' (is_url=False)", flush=True)
return (norm, False)
class LocalHTTPBridge(QObject):
request_upload = Signal(str)
local_http_bridge = LocalHTTPBridge()
class LocalHTTPHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
parsed = urllib.parse.urlparse(self.path)
query = urllib.parse.parse_qs(parsed.query)
if parsed.path in ("/upload", "/upload/") and "url" in query:
target_url = query["url"][0]
print(f"[f0ckm-gui] [LOCAL HTTP API] Received direct upload request for URL: {target_url}", flush=True)
local_http_bridge.request_upload.emit(target_url)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(b'{"success": true, "msg": "Queued upload"}')
elif parsed.path in ("/ping", "/ping/"):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(b'{"status": "ok", "app": "f0ckm-uploader"}')
else:
self.send_response(404)
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(b'{"error": "Not Found"}')
except Exception as e:
print(f"[f0ckm-gui] [LOCAL HTTP ERROR] {e}", flush=True)
def log_message(self, format, *args):
pass
def start_local_http_server(tray_app, port=18739):
try:
server = HTTPServer(("127.0.0.1", port), LocalHTTPHandler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
print(f"[f0ckm-gui] [LOCAL HTTP API] Started loopback server on http://127.0.0.1:{port}", flush=True)
return server
except Exception as e:
print(f"[f0ckm-gui] [LOCAL HTTP API WARNING] Could not start HTTP server on port {port}: {e}", flush=True)
return None
def copy_to_clipboard(text: str):
if not text:
return
@@ -53,14 +148,6 @@ def copy_to_clipboard(text: str):
subprocess.run(["xsel", "--clipboard", "--input"], input=text.encode("utf-8"), check=False)
except Exception as e:
print(f"xsel error: {e}")
from PySide6.QtCore import Qt, QThread, QObject, Signal, QUrl, QFile, QIODevice, QTimer, QMimeData, QProcess
from PySide6.QtGui import QIcon, QAction, QPixmap, QPainter, QColor, QFont, QPen, QImage, QDesktopServices, QDrag, QClipboard
from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QHttpMultiPart, QHttpPart, QNetworkReply, QLocalServer, QLocalSocket
from PySide6.QtWidgets import (
QApplication, QSystemTrayIcon, QMenu, QDialog, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QGroupBox,
QFormLayout, QMessageBox, QFileDialog, QSlider, QScrollArea, QFrame, QGridLayout, QWidget, QSizePolicy
)
# Configuration paths
CONFIG_DIR = os.path.expanduser("~/.config/f0ckm-uploader")
@@ -1047,6 +1134,117 @@ class StreamingMultipartEncoder:
self.total_size = len(self.header_bytes) + self.file_size + len(self.footer_bytes)
self.content_type = f"multipart/form-data; boundary={self.boundary}"
class URLPostThread(QThread):
finished = Signal(bool, str) # success, response_text_or_error
def __init__(self, api_url, api_key, url_param, rating, tags, visibility, is_oc, parent=None):
super().__init__(parent)
self.api_url = api_url
self.api_key = api_key
self.url_param = url_param
self.rating = rating
self.tags = tags
self.visibility = visibility
self.is_oc = is_oc
def run(self):
print(f"[f0ckm-gui] [URL POST START] Sending JSON URL payload '{self.url_param}' to '{self.api_url}'", flush=True)
try:
payload = {
"url": self.url_param,
"tags": self.tags if self.tags else "url,upload",
"visibility": str(self.visibility) if self.visibility is not None else "0"
}
if self.rating and str(self.rating).lower() not in ["", "default", "none"]:
rating_map = {"s": "sfw", "q": "nsfw", "e": "nsfl", "sfw": "sfw", "nsfw": "nsfw", "nsfl": "nsfl"}
payload["rating"] = rating_map.get(str(self.rating).lower(), str(self.rating).lower())
if self.is_oc:
payload["is_oc"] = "1"
json_bytes = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
self.api_url,
data=json_bytes,
headers={
"User-Agent": "f0ckm-uploader/2.0",
"X-Api-Key": self.api_key,
"Content-Type": "application/json"
},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as resp:
resp_text = resp.read().decode("utf-8")
print(f"[f0ckm-gui] [URL POST OK] Server Response: {resp_text}", flush=True)
self.finished.emit(True, resp_text)
except Exception as e:
print(f"[f0ckm-gui] [URL POST ERROR] {e}", flush=True)
self.finished.emit(False, str(e))
class StreamingDownloadThread(QThread):
progress = Signal(int, int) # bytes_received, total_bytes
finished = Signal(bool, str) # success, file_path_or_error_msg
def __init__(self, url, parent=None):
super().__init__(parent)
self.url = url
self.aborted = False
def abort(self):
self.aborted = True
def run(self):
print(f"[f0ckm-gui] [DOWNLOAD START] Requesting remote URL: {self.url}", flush=True)
try:
req = urllib.request.Request(
self.url,
headers={
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
)
with urllib.request.urlopen(req, timeout=30) as resp:
total_size = int(resp.headers.get("Content-Length", 0))
content_type = resp.headers.get("Content-Type", "")
print(f"[f0ckm-gui] [DOWNLOAD HTTP OK] Content-Type: '{content_type}', Length: {total_size} bytes", flush=True)
url_path = urllib.parse.urlparse(self.url).path
ext = os.path.splitext(url_path)[1].lower()
if not ext or len(ext) > 6:
mime_base = content_type.split(";")[0].strip() if content_type else ""
ext = mimetypes.guess_extension(mime_base) or ".png"
if ext == ".jpe":
ext = ".jpg"
clean_name = os.path.basename(url_path) or f"remote_{int(time.time())}"
if not os.path.splitext(clean_name)[1]:
clean_name += ext
temp_dir = tempfile.gettempdir()
temp_path = os.path.join(temp_dir, f"f0ckm_remote_{int(time.time())}_{clean_name}")
received = 0
chunk_size = 32768
with open(temp_path, "wb") as f:
while True:
if self.aborted:
print("[f0ckm-gui] [DOWNLOAD ABORTED]", flush=True)
self.finished.emit(False, "ABORTED")
return
chunk = resp.read(chunk_size)
if not chunk:
break
f.write(chunk)
received += len(chunk)
self.progress.emit(received, total_size)
print(f"[f0ckm-gui] [DOWNLOAD COMPLETE] Saved remote file to: {temp_path} ({received} bytes)", flush=True)
self.finished.emit(True, temp_path)
except Exception as e:
print(f"[f0ckm-gui] [DOWNLOAD ERROR] Failed to fetch {self.url}: {e}", flush=True)
if self.aborted:
self.finished.emit(False, "ABORTED")
else:
self.finished.emit(False, str(e))
class StreamingUploadThread(QThread):
progress = Signal(int, int) # bytes_sent, bytes_total
finished = Signal(bool, str) # success, response_or_error_msg
@@ -1072,6 +1270,7 @@ class StreamingUploadThread(QThread):
pass
def run(self):
print(f"[f0ckm-gui] [UPLOAD START] File: '{self.file_path}' -> Target API: '{self.api_url}'", flush=True)
fields = {
"rating": self.rating,
"tags": self.tags,
@@ -1232,6 +1431,10 @@ class SystemTrayApp(QObject):
upload_action.triggered.connect(self.on_upload_file)
self.menu.addAction(upload_action)
upload_url_action = QAction("Upload URL...", self.menu)
upload_url_action.triggered.connect(self.on_upload_url)
self.menu.addAction(upload_url_action)
clipboard_action = QAction("Upload from Clipboard", self.menu)
clipboard_action.triggered.connect(self.on_upload_clipboard)
self.menu.addAction(clipboard_action)
@@ -1257,10 +1460,18 @@ class SystemTrayApp(QObject):
self.tray.setContextMenu(self.menu)
self.tray.activated.connect(self.on_tray_activated)
self.last_uploaded_url = ""
self.tray.messageClicked.connect(self.on_notification_clicked)
self.settings_dialog = None
self.gallery_window = None
def on_notification_clicked(self):
url = getattr(self, "last_uploaded_url", "")
if url:
print(f"[f0ckm-gui] Notification clicked -> Opening URL in browser: '{url}'", flush=True)
QDesktopServices.openUrl(QUrl(url))
def on_open_gallery(self):
if self.gallery_window is None:
self.gallery_window = GalleryWindow()
@@ -1327,11 +1538,114 @@ class SystemTrayApp(QObject):
self.upload_file_direct(save_path)
def on_abort_upload(self):
if hasattr(self, "download_thread") and self.download_thread and self.download_thread.isRunning():
self.download_thread.abort()
if self.current_reply and self.current_reply.isRunning():
self.current_reply.abort()
elif hasattr(self, "upload_thread") and self.upload_thread:
elif hasattr(self, "upload_thread") and self.upload_thread and self.upload_thread.isRunning():
self.upload_thread.abort()
def upload_target_direct(self, target):
target_str, is_url = parse_upload_target(target)
print(f"[f0ckm-gui] [TRAY ACTION] Received target: '{target}' -> target_str='{target_str}', is_url={is_url}", flush=True)
if not target_str:
return
if is_url:
self.download_and_upload_url(target_str)
else:
self.upload_file_direct(target_str)
def download_and_upload_url(self, url):
config = get_env_config()
api_url = config.get("api_url", "")
api_key = config.get("api_key", "")
rating = config.get("default_rating", "")
tags = config.get("default_tags", "")
visibility = config.get("default_visibility", "0")
is_oc = config.get("default_is_oc", False)
print(f"[f0ckm-gui] [URL UPLOAD] Initiating URL upload: '{url}' (API URL: '{api_url}')", flush=True)
if not api_url or not api_key:
print(f"[f0ckm-gui] [CONFIG ERROR] API URL or API Key missing in configuration!", flush=True)
self.show_message("Upload Error", "API URL and API Key must be set in Settings.", QSystemTrayIcon.Critical)
return
self.download_url = url
self.download_start_time = time.time()
self.tray.setIcon(create_digit_icon(50))
self.tray.setToolTip(f"Uploading URL to f0ckm...\n{url[:45]}")
self.url_post_thread = URLPostThread(api_url, api_key, url, rating, tags, visibility, is_oc)
self.url_post_thread.finished.connect(lambda ok, res: self.on_url_post_finished(ok, res, url))
self.url_post_thread.start()
self.abort_action.setEnabled(True)
def on_url_post_finished(self, ok, result, url):
if ok:
try:
data = json.loads(result)
if data.get("success") and (data.get("url") or data.get("file_url") or data.get("direct_url") or data.get("post_url") or data.get("file")):
post_url = data.get("url") or data.get("post_url") or ""
direct_url = data.get("file_url") or data.get("direct_url") or data.get("file") or ""
config = get_env_config()
url_type = config.get("clipboard_url_type", "post").lower()
final_url = direct_url if (url_type == "direct" and direct_url) else (post_url or direct_url)
copy_to_clipboard(final_url)
play_success_sound(config.get("sound_file"), config.get("sound_volume"))
entry = {
"timestamp": int(time.time()),
"file_path": url,
"file_name": url.split("/")[-1] or "url_upload",
"file_size": 0,
"url": final_url,
"post_url": post_url,
"file_url": direct_url
}
add_history_entry(entry)
self.abort_action.setEnabled(False)
self.tray.setIcon(self.default_icon)
self.tray.setToolTip("f0ckm Uploader (Click to capture & upload)")
self.last_uploaded_url = final_url
self.show_success_notification(url, final_url)
return
except Exception as e:
print(f"[f0ckm-gui] JSON parse notice: {e}", flush=True)
print(f"[f0ckm-gui] Direct URL API notice: Falling back to local download of '{url}'", flush=True)
self.download_thread = StreamingDownloadThread(url)
self.download_thread.progress.connect(self.on_download_progress)
self.download_thread.finished.connect(lambda ok_dl, res_dl: self.on_download_finished(ok_dl, res_dl, url))
self.download_thread.start()
def on_download_progress(self, bytes_received, bytes_total):
if bytes_total > 0:
pct = min(99, max(0, int((bytes_received / bytes_total) * 100)))
self.tray.setIcon(create_digit_icon(pct))
rec_str = format_size(bytes_received)
tot_str = format_size(bytes_total)
url_str = getattr(self, "download_url", "")
short_url = (url_str[:35] + "...") if len(url_str) > 35 else url_str
self.tray.setToolTip(f"Downloading: {short_url}\n{pct}% ({rec_str} / {tot_str})")
else:
rec_str = format_size(bytes_received)
self.tray.setToolTip(f"Downloading remote file...\n{rec_str}")
def on_download_finished(self, success, result, url):
if not success:
self.abort_action.setEnabled(False)
self.tray.setIcon(self.default_icon)
self.tray.setToolTip("f0ckm Uploader (Click to capture & upload)")
if result == "ABORTED":
self.show_message("Download Aborted", "Remote URL download was canceled.", QSystemTrayIcon.Information)
else:
self.show_message("Download Failed", f"Failed to download remote URL: {result}", QSystemTrayIcon.Critical)
return
self.upload_file_direct(result)
def upload_file_direct(self, file_path):
file_path = normalize_file_path(file_path)
if not file_path or not os.path.exists(file_path):
@@ -1464,6 +1778,7 @@ class SystemTrayApp(QObject):
self.show_message("Upload Error", f"Error parsing response: {e}\nResponse: {response_text}", QSystemTrayIcon.Critical)
def show_success_notification(self, file_path, item_url):
self.last_uploaded_url = item_url
config = get_env_config()
if config.get("play_success_sound", False) or config.get("success_audio_path"):
audio_path = config.get("success_audio_path", "")
@@ -1525,6 +1840,11 @@ class SystemTrayApp(QObject):
)
if file_path:
self.upload_file_direct(file_path)
def on_upload_url(self):
url, ok = QInputDialog.getText(None, "Upload URL", "Enter image or file URL:")
if ok and url.strip():
self.upload_target_direct(url.strip())
def on_upload_clipboard(self):
clipboard = QApplication.clipboard()
@@ -1586,14 +1906,19 @@ class SingleInstanceApp:
if arg in ("--spectacle", "-s"):
msg = "spectacle"
elif arg.startswith("--upload="):
msg = f"upload:{arg[9:]}"
target, _ = parse_upload_target(arg[9:])
msg = f"upload:{target}"
elif arg.startswith("--upload"):
if len(sys.argv) > 2:
msg = f"upload:{sys.argv[2]}"
target, _ = parse_upload_target(sys.argv[2])
msg = f"upload:{target}"
else:
msg = "show"
else:
norm = normalize_file_path(arg)
if os.path.exists(norm):
msg = f"upload:{norm}"
target, _ = parse_upload_target(arg)
if target:
msg = f"upload:{target}"
print(f"[f0ckm-gui] [IPC CLIENT] Daemon running. Forwarding message via socket: '{msg}'", flush=True)
socket.write(msg.encode("utf-8"))
socket.waitForBytesWritten(500)
socket.disconnectFromServer()
@@ -1603,6 +1928,7 @@ class SingleInstanceApp:
if not self.server.listen(self.name):
return False
print(f"[f0ckm-gui] [IPC SERVER] Started single-instance IPC listener socket server: '{self.name}'", flush=True)
self.server.newConnection.connect(lambda: self._handle_connection(callback_on_message))
return True
@@ -1613,6 +1939,7 @@ class SingleInstanceApp:
def _read_message(self, socket, callback):
data = socket.readAll().data().decode("utf-8").strip()
print(f"[f0ckm-gui] [IPC SERVER] Received message from socket: '{data}'", flush=True)
callback(data)
socket.disconnectFromServer()
@@ -1637,11 +1964,8 @@ def main():
if msg_str == "spectacle":
tray_app.on_capture_and_upload()
elif msg_str.startswith("upload:"):
file_path = normalize_file_path(msg_str[7:])
if os.path.exists(file_path):
tray_app.upload_file_direct(file_path)
else:
tray_app.show_message("Upload Error", f"File not found: {file_path}", QSystemTrayIcon.Critical)
target = msg_str[7:]
tray_app.upload_target_direct(target)
else:
tray_app.on_open_settings()
@@ -1652,7 +1976,9 @@ def main():
save_config(DEFAULT_CONFIG)
tray_app = SystemTrayApp()
local_http_bridge.request_upload.connect(tray_app.upload_target_direct)
tray_app.show()
start_local_http_server(tray_app)
# Check if started with action argument
if len(sys.argv) > 1:
@@ -1660,16 +1986,16 @@ def main():
if arg in ("--spectacle", "-s"):
tray_app.on_capture_and_upload()
elif arg.startswith("--upload="):
file_path = normalize_file_path(arg[9:])
tray_app.upload_file_direct(file_path)
target, _ = parse_upload_target(arg[9:])
tray_app.upload_target_direct(target)
elif arg.startswith("--upload"):
if len(sys.argv) > 2:
file_path = normalize_file_path(sys.argv[2])
tray_app.upload_file_direct(file_path)
target, _ = parse_upload_target(sys.argv[2])
tray_app.upload_target_direct(target)
else:
norm = normalize_file_path(arg)
if os.path.exists(norm):
tray_app.upload_file_direct(norm)
target, _ = parse_upload_target(arg)
if target:
tray_app.upload_target_direct(target)
sys.exit(app.exec())