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

View File

@@ -10,11 +10,13 @@ A modern, high-contrast **KDE Plasma System Tray Uploader** and desktop integrat
- **Single-Instance IPC Architecture**: 100% Python-based daemon (`PySide6`). Multiple invocations (hotkeys, Dolphin context menu) delegate directly to the running tray instance without spawning duplicates.
- **Dolphin Context Menu Integration**: Right-click any file in Dolphin -> **Actions** -> **Upload to f0ckm**.
- **Spectacle Screenshot Capture**: Region capture integration via hotkeys or menu trigger (`f0ckm-uploader-gui --spectacle`).
- **Browser Extension Addon (ShareX Style)**: Right-click any image, video, link, or URL on the web in Chrome, Firefox, Brave, or Edge -> **Upload with f0ckm**.
- **Custom Protocol Scheme (`f0ckm://`)**: Seamless URL handling (`f0ckm://upload?url=...`) delegating background remote file downloading & live tray progress directly to the system tray app.
- **Smart Notification Previews**:
- **Images**: Fast native scaling via PySide6.
- **Videos** (`.mp4`, `.webm`, etc.): Automatic frame extraction at `00:00:01` via `ffmpeg`.
- **Other Files**: Sleek placeholder file badges showing the file extension.
- **Clipboard & Settings GUI**: Direct clipboard image/path uploads and a built-in Settings dialog for configuring API keys, default tags, visibility, rating, and autostart.
- **Clipboard & Settings GUI**: Direct clipboard image/path uploads, URL dialog uploads, and a built-in Settings dialog for configuring API keys, default tags, visibility, rating, and autostart.
---
@@ -104,6 +106,7 @@ To bind a keyboard shortcut to capture a screen region and upload it immediately
- **Right Click**: Opens context menu for:
- **Capture Region & Upload**
- **Upload File...**
- **Upload URL...**
- **Upload from Clipboard**
- **Settings...**
- **Quit**
@@ -119,15 +122,42 @@ To bind a keyboard shortcut to capture a screen region and upload it immediately
# Capture screen region and upload
f0ckm-uploader-gui --spectacle
# Upload a specific file
# Upload a specific local file
f0ckm-uploader-gui --upload="/path/to/file.mp4"
# Upload a remote image or media URL
f0ckm-uploader-gui --upload="https://example.com/image.png"
# Launch via custom protocol handler
f0ckm-uploader-gui "f0ckm://upload?url=https://example.com/image.png"
# Open settings dialog
f0ckm-uploader-gui
```
---
## Browser Extension (ShareX Addon)
A WebExtension is provided in the `browser-extension/` directory.
### Installation
1. **Chrome / Brave / Edge**:
- Go to `chrome://extensions`, enable **Developer mode**.
- Click **Load unpacked** and select the `browser-extension` folder.
2. **Firefox**:
- Go to `about:debugging#/runtime/this-firefox`.
- Click **Load Temporary Add-on...** and select `browser-extension/manifest.json`.
### Features
- **Context Menu**: Right-click any image, video, audio link, or URL on any webpage -> **Upload to f0ckm**.
- **Toolbar Popup**: Click extension icon to upload active tab or paste a URL.
- **Keyboard Shortcut**: `Alt+Shift+U` to upload current tab URL.
---
## Configuration & Storage Paths
- **Configuration File**: `~/.config/f0ckm-uploader/config.json`

View File

@@ -0,0 +1,58 @@
# f0ckm Browser Integration Addon
A ShareX-style browser extension for **Chrome, Brave, Edge, Opera, and Firefox** that allows you to upload images, media, links, and URLs directly to **f0ckm** from any web browser context menu or toolbar popup.
---
## Features
- **Right-Click Context Menu (ShareX Style)**:
- **Upload image to f0ckm** (on any image)
- **Upload media to f0ckm** (on video/audio elements)
- **Upload link with f0ckm** (on links)
- **Upload selected URL with f0ckm** (on highlighted text URLs)
- **Upload page URL to f0ckm** (on page background)
- **Dual Upload Modes**:
- **Desktop App Mode (`f0ckm://`)** *(Recommended)*: Hands off downloads and uploads to the KDE desktop system tray app. Gives you live system tray digit progress (`0``99`%), Spectacle notifications, audio sounds, and local history gallery.
- **Direct API Mode**: Uploads directly from the browser background page using API URL and API Key without needing the tray app running.
- **Toolbar Popup Interface**:
- Upload current active tab URL with one click.
- Paste any URL for quick upload.
- Quick toggle between Desktop App Mode and Direct API Mode.
- **Global Keyboard Shortcut**: `Alt+Shift+U` to immediately upload current page URL.
---
## Installation Instructions
### Chrome, Brave, Edge, Opera (Chromium)
1. Open your browser and navigate to `chrome://extensions` (or `edge://extensions` in Edge).
2. Enable **Developer mode** (toggle in the top-right corner).
3. Click **Load unpacked** in the top-left menu.
4. Select the `browser-extension` folder in this repository (`/home/kibi/Projects/f0ckm-uploader/browser-extension`).
5. Pin the **f0ckm Uploader** icon to your toolbar for easy access!
### Mozilla Firefox
1. Open Firefox and navigate to `about:debugging#/runtime/this-firefox`.
2. Click **Load Temporary Add-on...**
3. Select `manifest.json` from the `browser-extension` folder.
---
## Usage
1. **Right-Click Context Menu**: Right-click any image, video, audio link, or page -> select **Upload to f0ckm**.
2. **Toolbar Popup**: Click the f0ckm icon in your browser toolbar to upload the active tab or paste a URL.
3. **Keyboard Shortcut**: Press `Alt+Shift+U` on any tab.
---
## Configuration
Click **Settings** (or right-click extension icon -> Options) to configure:
- **Upload Mode**: Desktop App (f0ckm://) vs Direct API.
- **API Endpoint URL**: Default `https://your-f0ckm-instance.com/api/v2/upload`.
- **API Key**: Secret API key for direct API mode.
- **Default Rating / Tags / Visibility / OC / Copied Link Format**.

View File

@@ -0,0 +1,242 @@
// f0ckm Uploader - Background Service Worker
const DEFAULT_SETTINGS = {
uploadMode: "desktop", // "desktop" (f0ckm://) or "direct" (API)
apiUrl: "https://f0ckm.com/api/v2/upload",
apiKey: "",
rating: "",
tags: "",
visibility: "0",
isOc: false,
urlType: "post"
};
async function getSettings() {
return new Promise((resolve) => {
chrome.storage.sync.get(DEFAULT_SETTINGS, (items) => {
resolve(items || DEFAULT_SETTINGS);
});
});
}
const badgeCache = {};
function updateBadge(tabId, url) {
if (!tabId) return;
const isWeb = url && (url.startsWith("http://") || url.startsWith("https://"));
const isYT = isWeb && (url.includes("youtube.com") || url.includes("youtu.be"));
const targetText = isWeb ? "1" : "";
const targetColor = isYT ? "#ff0000" : "#0066ff";
const cacheKey = `${targetText}:${targetColor}`;
if (badgeCache[tabId] === cacheKey) {
return;
}
badgeCache[tabId] = cacheKey;
try {
if (targetText) {
chrome.action.setBadgeText({ text: targetText, tabId: tabId });
chrome.action.setBadgeBackgroundColor({ color: targetColor, tabId: tabId });
} else {
chrome.action.setBadgeText({ text: "", tabId: tabId });
}
} catch (e) {}
}
chrome.tabs.onActivated.addListener(async (activeInfo) => {
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab) updateBadge(activeInfo.tabId, tab.url);
} catch (e) {}
});
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (tab && (changeInfo.status === "complete" || changeInfo.url)) {
updateBadge(tabId, tab.url);
}
});
function setupContextMenus() {
chrome.contextMenus.removeAll(() => {
chrome.contextMenus.create({
id: "f0ckm_upload_image",
title: "Upload image to f0ckm",
contexts: ["image"]
});
chrome.contextMenus.create({
id: "f0ckm_upload_video",
title: "Upload media to f0ckm",
contexts: ["video", "audio"]
});
chrome.contextMenus.create({
id: "f0ckm_upload_link",
title: "Upload link with f0ckm",
contexts: ["link"]
});
chrome.contextMenus.create({
id: "f0ckm_upload_selection",
title: "Upload selected URL with f0ckm",
contexts: ["selection"]
});
chrome.contextMenus.create({
id: "f0ckm_upload_page",
title: "Upload page URL to f0ckm",
contexts: ["page"]
});
});
}
chrome.runtime.onInstalled.addListener(() => {
setupContextMenus();
});
chrome.runtime.onStartup.addListener(() => {
setupContextMenus();
});
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
let targetUrl = "";
if (info.menuItemId === "f0ckm_upload_image" || info.menuItemId === "f0ckm_upload_video") {
targetUrl = info.srcUrl || info.linkUrl;
} else if (info.menuItemId === "f0ckm_upload_link") {
targetUrl = info.linkUrl;
} else if (info.menuItemId === "f0ckm_upload_selection") {
targetUrl = info.selectionText ? info.selectionText.trim() : "";
} else if (info.menuItemId === "f0ckm_upload_page") {
targetUrl = info.pageUrl || (tab ? tab.url : "");
}
if (targetUrl) {
await processUpload(targetUrl);
}
});
chrome.commands.onCommand.addListener(async (command) => {
if (command === "upload-page") {
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
if (tabs && tabs.length > 0 && tabs[0].url) {
await processUpload(tabs[0].url);
}
}
});
async function processUpload(targetUrl) {
if (!targetUrl) return;
const settings = await getSettings();
if (settings.uploadMode === "desktop") {
// 1. Try local loopback HTTP API first (Direct desktop handoff)
const localApiUrl = "http://127.0.0.1:18739/upload?url=" + encodeURIComponent(targetUrl);
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 1500);
const res = await fetch(localApiUrl, { signal: controller.signal });
clearTimeout(timeoutId);
if (res.ok) {
return;
}
} catch (err) {}
// 2. Fallback: Custom protocol scheme in active tab
const customUrl = "f0ckm://upload?url=" + encodeURIComponent(targetUrl);
try {
chrome.tabs.create({ url: customUrl, active: true }, (newTab) => {
if (newTab && newTab.id) {
setTimeout(() => {
chrome.tabs.remove(newTab.id).catch(() => {});
}, 4500);
}
});
} catch (e) {}
} else {
// Direct API Upload mode -> POST JSON payload with url to f0ckm API!
showNotification("Uploading...", `Uploading ${targetUrl.substring(0, 45)}...`, "uploading");
try {
if (!settings.apiUrl || !settings.apiKey) {
showNotification("Upload Error", "API URL and API Key must be configured in extension options.", "error");
return;
}
const payload = {
url: targetUrl,
tags: settings.tags || "url,upload",
visibility: settings.visibility || "0"
};
if (settings.rating && settings.rating !== "default") {
const ratingMap = { s: "sfw", q: "nsfw", e: "nsfl", sfw: "sfw", nsfw: "nsfw", nsfl: "nsfl" };
payload.rating = ratingMap[settings.rating.toLowerCase()] || settings.rating;
}
if (settings.isOc) payload.is_oc = "1";
const uploadRes = await fetch(settings.apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": settings.apiKey
},
body: JSON.stringify(payload)
});
const json = await uploadRes.json();
if (json.success && (json.url || json.file_url || json.direct_url || json.target_url || json.file)) {
const postUrl = json.url || json.post_url || "";
const directUrl = json.file_url || json.direct_url || json.target_url || json.file || "";
const resultUrl = (settings.urlType === "direct" && directUrl) ? directUrl : (postUrl || directUrl);
showNotification("Upload Successful!", `Link: ${resultUrl}`, "success");
return resultUrl;
} else {
const msg = json.msg || "Upload failed";
showNotification("Upload Failed", msg, "error");
}
} catch (err) {
showNotification("Upload Error", err.message || "An error occurred", "error");
}
}
}
function showNotification(title, message, iconType = "info") {
if (chrome.notifications) {
chrome.notifications.create({
type: "basic",
iconUrl: "icons/icon128.png",
title: title,
message: message,
priority: 2
});
}
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === "get_active_tab") {
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs) => {
if (tabs && tabs.length > 0 && tabs[0].url) {
sendResponse({ tab: tabs[0] });
} else {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs2) => {
if (tabs2 && tabs2.length > 0 && tabs2[0].url) {
sendResponse({ tab: tabs2[0] });
} else {
chrome.tabs.query({ active: true }, (tabs3) => {
sendResponse({ tab: (tabs3 && tabs3.length > 0) ? tabs3[0] : null });
});
}
});
}
});
return true;
} else if (request.action === "upload_url") {
processUpload(request.url).then((resUrl) => {
sendResponse({ status: "ok", resultUrl: resUrl || "" });
});
return true;
}
});

View File

@@ -0,0 +1,46 @@
{
"manifest_version": 3,
"name": "f0ckm Uploader",
"version": "2.0",
"description": "Upload images, media, links, and URLs directly to f0ckm from context menu or toolbar.",
"permissions": [
"contextMenus",
"storage",
"notifications",
"activeTab",
"clipboardWrite"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_popup": "popup/popup.html",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": true
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"commands": {
"upload-page": {
"suggested_key": {
"default": "Alt+Shift+U"
},
"description": "Upload current page URL to f0ckm"
}
}
}

View File

@@ -0,0 +1,50 @@
{
"manifest_version": 3,
"name": "f0ckm Uploader",
"version": "2.0",
"description": "Upload images, media, links, and URLs directly to f0ckm from context menu or toolbar.",
"permissions": [
"contextMenus",
"storage",
"notifications",
"activeTab",
"tabs",
"clipboardWrite"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"scripts": ["background.js"]
},
"action": {
"default_popup": "popup/popup.html",
"default_title": "f0ckm Uploader",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"default_area": "navbar"
},
"options_ui": {
"page": "options/options.html",
"open_in_tab": true
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"commands": {
"upload-page": {
"suggested_key": {
"default": "Alt+Shift+U"
},
"description": "Upload current page URL to f0ckm"
}
}
}

View File

@@ -0,0 +1,197 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
body {
background-color: #0f1015;
color: #dce0ed;
font-size: 14px;
line-height: 1.5;
padding: 40px 20px;
display: flex;
justify-content: center;
}
.container {
width: 100%;
max-width: 640px;
background: #161822;
border: 1px solid #242738;
border-radius: 12px;
padding: 28px 32px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
.header {
display: flex;
align-items: center;
gap: 16px;
padding-bottom: 20px;
border-bottom: 1px solid #242738;
margin-bottom: 24px;
}
.logo {
width: 48px;
height: 48px;
}
h1 {
font-size: 20px;
font-weight: 700;
color: #ffffff;
}
.subtitle {
font-size: 13px;
color: #7a829a;
}
.section {
margin-bottom: 24px;
}
h2 {
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #4da6ff;
margin-bottom: 12px;
}
.field-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-label {
display: flex;
align-items: flex-start;
gap: 12px;
background: #1c1e2d;
border: 1px solid #2a2e44;
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s ease;
}
.radio-label:hover {
border-color: #3d4464;
}
.radio-label input[type="radio"] {
margin-top: 3px;
accent-color: #0066ff;
}
.radio-content strong {
display: block;
font-size: 13px;
color: #ffffff;
}
.radio-content p {
font-size: 12px;
color: #8c93aa;
margin-top: 2px;
}
.field {
margin-bottom: 14px;
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
font-weight: 600;
color: #b0b6cc;
}
input[type="text"],
input[type="url"],
input[type="password"],
select {
background: #12131b;
border: 1px solid #292d42;
color: #ffffff;
padding: 9px 12px;
border-radius: 7px;
font-size: 13px;
outline: none;
width: 100%;
transition: border-color 0.2s ease;
}
input:focus, select:focus {
border-color: #0066ff;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.field-row {
margin-bottom: 14px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
font-size: 13px;
color: #dce0ed;
}
.checkbox-label input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: #0066ff;
cursor: pointer;
}
.actions {
display: flex;
align-items: center;
gap: 14px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid #242738;
}
.btn-save {
background: linear-gradient(135deg, #0066ff, #0044cc);
color: #ffffff;
border: none;
padding: 10px 20px;
font-size: 13px;
font-weight: 700;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-save:hover {
background: linear-gradient(135deg, #1a75ff, #0052e6);
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.3);
}
.toast {
font-size: 13px;
font-weight: 600;
color: #2ed573;
}
.toast.hidden {
display: none;
}

View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>f0ckm Extension Options</title>
<link rel="stylesheet" href="options.css">
</head>
<body>
<div class="container">
<div class="header">
<img src="../icons/icon48.png" alt="f0ckm Icon" class="logo">
<div>
<h1>f0ckm Browser Integration Settings</h1>
<p class="subtitle">Configure browser extension upload modes and API settings</p>
</div>
</div>
<form id="settings-form">
<div class="section">
<h2>Integration Mode</h2>
<div class="field-group">
<label class="radio-label">
<input type="radio" name="uploadMode" value="desktop" id="mode-desktop">
<div class="radio-content">
<strong>Desktop Application Integration (f0ckm://)</strong>
<p>Delegates downloads & uploads directly to the KDE system tray app. Provides live tray digit icons, Spectacle notifications, sound effects, and local gallery history.</p>
</div>
</label>
<label class="radio-label">
<input type="radio" name="uploadMode" value="direct" id="mode-direct">
<div class="radio-content">
<strong>Direct Browser Extension API Upload</strong>
<p>Uploads files directly from the browser background page using API URL and API Key set below.</p>
</div>
</label>
</div>
</div>
<div class="section">
<h2>API & Server Configuration</h2>
<div class="field">
<label for="apiUrl">API Endpoint URL</label>
<input type="url" id="apiUrl" placeholder="https://your-f0ckm-instance.com/api/v2/upload">
</div>
<div class="field">
<label for="apiKey">API Key</label>
<input type="password" id="apiKey" placeholder="Enter your secret API key">
</div>
</div>
<div class="section">
<h2>Default Upload Preferences</h2>
<div class="grid-2">
<div class="field">
<label for="rating">Default Rating</label>
<select id="rating">
<option value="">Default (Untagged)</option>
<option value="sfw">Safe (sfw)</option>
<option value="nsfw">Questionable (nsfw)</option>
<option value="nsfl">Explicit (nsfl)</option>
</select>
</div>
<div class="field">
<label for="visibility">Default Visibility</label>
<select id="visibility">
<option value="0">Public (0)</option>
<option value="1">Unlisted (1)</option>
<option value="2">Private (2)</option>
</select>
</div>
</div>
<div class="field">
<label for="tags">Default Tags</label>
<input type="text" id="tags" placeholder="e.g. browser, web, media">
</div>
<div class="field-row">
<label class="checkbox-label">
<input type="checkbox" id="isOc">
<span>Mark uploads as Original Content (OC)</span>
</label>
</div>
<div class="field">
<label for="urlType">Copied Link Format</label>
<select id="urlType">
<option value="post">f0ckm Post URL (Viewer page)</option>
<option value="direct">Direct File Link (Raw image/media file)</option>
</select>
</div>
</div>
<div class="actions">
<button type="submit" id="btn-save" class="btn-save">Save Settings</button>
<span id="save-toast" class="toast hidden">✓ Settings saved successfully!</span>
</div>
</form>
</div>
<script src="options.js"></script>
</body>
</html>

View File

@@ -0,0 +1,63 @@
const DEFAULT_SETTINGS = {
uploadMode: "desktop",
apiUrl: "https://f0ckm.com/api/v2/upload",
apiKey: "",
rating: "s",
tags: "",
visibility: "0",
isOc: false,
urlType: "post"
};
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("settings-form");
const modeDesktop = document.getElementById("mode-desktop");
const modeDirect = document.getElementById("mode-direct");
const apiUrl = document.getElementById("apiUrl");
const apiKey = document.getElementById("apiKey");
const rating = document.getElementById("rating");
const visibility = document.getElementById("visibility");
const tags = document.getElementById("tags");
const isOc = document.getElementById("isOc");
const urlType = document.getElementById("urlType");
const saveToast = document.getElementById("save-toast");
// Load existing settings
chrome.storage.sync.get(DEFAULT_SETTINGS, (items) => {
if (items.uploadMode === "direct") {
modeDirect.checked = true;
} else {
modeDesktop.checked = true;
}
apiUrl.value = items.apiUrl || "";
apiKey.value = items.apiKey || "";
rating.value = items.rating || "s";
visibility.value = items.visibility !== undefined ? String(items.visibility) : "0";
tags.value = items.tags || "";
isOc.checked = !!items.isOc;
urlType.value = items.urlType || "post";
});
// Save settings on form submit
form.addEventListener("submit", (e) => {
e.preventDefault();
const newSettings = {
uploadMode: modeDirect.checked ? "direct" : "desktop",
apiUrl: apiUrl.value.trim(),
apiKey: apiKey.value.trim(),
rating: rating.value,
visibility: visibility.value,
tags: tags.value.trim(),
isOc: isOc.checked,
urlType: urlType.value
};
chrome.storage.sync.set(newSettings, () => {
saveToast.classList.remove("hidden");
setTimeout(() => {
saveToast.classList.add("hidden");
}, 2500);
});
});
});

View File

@@ -0,0 +1,317 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}
html, body {
width: 340px;
min-width: 340px;
min-height: 240px;
margin: 0;
padding: 0;
background-color: #121318;
color: #e2e4ec;
font-size: 13px;
user-select: none;
}
.header {
display: flex;
align-items: center;
justify-content: center;
padding: 12px 14px;
background: #191b22;
border-bottom: 1px solid #282a36;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
}
.brand-icon {
width: 22px;
height: 22px;
}
.brand-name {
font-weight: 700;
font-size: 15px;
color: #ffffff;
letter-spacing: 0.4px;
}
.mode-selector {
display: flex;
background: #181920;
padding: 4px;
margin: 10px 14px 4px 14px;
border-radius: 8px;
border: 1px solid #242634;
}
.mode-option {
flex: 1;
text-align: center;
cursor: pointer;
}
.mode-option input {
display: none;
}
.mode-pill {
display: block;
padding: 6px 4px;
font-size: 11px;
font-weight: 600;
color: #8c92a4;
border-radius: 6px;
transition: all 0.2s ease;
}
.mode-option input:checked + .mode-pill {
background: #2b2f42;
color: #4da6ff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.content {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 10px;
}
.active-tab-card {
background: #191b26;
border: 1px solid #282c3f;
border-radius: 9px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.card-top {
display: flex;
align-items: center;
gap: 8px;
}
.site-badge {
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
background: #0066ff22;
color: #4da6ff;
border: 1px solid #0066ff55;
white-space: nowrap;
}
.site-badge.youtube {
background: #ff000022;
color: #ff4d4d;
border-color: #ff000055;
}
.tab-title {
font-size: 11px;
font-weight: 600;
color: #ffffff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tab-url-preview {
font-size: 10px;
color: #727a90;
font-family: monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: #111218;
padding: 4px 6px;
border-radius: 4px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
padding: 9px 12px;
border-radius: 7px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s ease;
}
.btn-lg {
padding: 11px 14px;
font-size: 13px;
font-weight: 700;
}
.btn-primary {
background: linear-gradient(135deg, #0066ff, #0044cc);
color: #ffffff;
box-shadow: 0 2px 10px rgba(0, 102, 255, 0.3);
}
.btn-primary:hover {
background: linear-gradient(135deg, #1a75ff, #0052e6);
box-shadow: 0 4px 14px rgba(0, 102, 255, 0.5);
}
.btn-primary.youtube-style {
background: linear-gradient(135deg, #e60000, #b30000);
box-shadow: 0 2px 10px rgba(230, 0, 0, 0.3);
}
.btn-primary.youtube-style:hover {
background: linear-gradient(135deg, #ff1a1a, #cc0000);
box-shadow: 0 4px 14px rgba(230, 0, 0, 0.5);
}
.btn-accent {
background: #252836;
color: #4da6ff;
border: 1px solid #363b50;
}
.btn-accent:hover {
background: #2e3346;
color: #80c0ff;
}
.btn-sm {
padding: 5px 8px;
font-size: 11px;
width: auto;
background: #0066ff;
color: #ffffff;
}
.divider {
display: flex;
align-items: center;
text-align: center;
color: #555c70;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.5px;
margin: 2px 0;
}
.divider::before, .divider::after {
content: '';
flex: 1;
border-bottom: 1px solid #222532;
}
.divider span {
padding: 0 8px;
}
.input-group {
display: flex;
gap: 6px;
}
.input-group input {
flex: 1;
background: #181a24;
border: 1px solid #282c3d;
color: #ffffff;
padding: 8px 10px;
border-radius: 7px;
font-size: 11px;
outline: none;
transition: border-color 0.2s ease;
}
.input-group input:focus {
border-color: #0066ff;
}
.status-card {
background: #181a24;
border: 1px solid #252838;
border-radius: 7px;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
text-align: center;
}
.status-card.hidden {
display: none;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid #2e3448;
border-top-color: #0066ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.status-text {
font-size: 11px;
color: #a0a6b8;
word-break: break-word;
}
.result-container {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
}
.result-container.hidden {
display: none;
}
.result-link {
flex: 1;
font-size: 11px;
color: #4da6ff;
text-decoration: underline;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
display: block;
}
.result-link:hover {
color: #80c0ff;
}
.footer {
display: flex;
justify-content: center;
align-items: center;
padding: 8px 14px;
background: #15161d;
border-top: 1px solid #20222d;
font-size: 10px;
color: #5b6276;
}

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>f0ckm Uploader</title>
<link rel="stylesheet" href="popup.css">
<script src="popup.js" defer></script>
</head>
<body>
<div class="header">
<div class="brand">
<img src="../icons/icon32.png" alt="f0ckm Logo" class="brand-icon">
<span class="brand-name">f0ckm Uploader</span>
</div>
</div>
<div class="mode-selector">
<label class="mode-option">
<input type="radio" name="uploadMode" value="desktop" id="mode-desktop">
<span class="mode-pill">Desktop App (f0ckm://)</span>
</label>
<label class="mode-option">
<input type="radio" name="uploadMode" value="direct" id="mode-direct">
<span class="mode-pill">Direct API</span>
</label>
</div>
<div class="content">
<div id="active-tab-card" class="active-tab-card">
<div class="card-top">
<span id="site-badge" class="site-badge">🌐 Web Page</span>
<span id="tab-title" class="tab-title">Loading current tab...</span>
</div>
<div id="tab-url-preview" class="tab-url-preview">https://...</div>
<button id="btn-upload-tab" class="btn btn-primary btn-lg">
<span class="btn-icon">🚀</span> <span id="btn-upload-text">Upload to f0ckm API</span>
</button>
</div>
<div class="divider"><span>OR PASTE CUSTOM URL</span></div>
<div class="input-group">
<input type="url" id="url-input" placeholder="https://youtube.com/watch?v=..." autocomplete="off" spellcheck="false">
<button id="btn-upload-url" class="btn btn-accent">Upload URL</button>
</div>
<div id="status-card" class="status-card hidden">
<div id="status-spinner" class="spinner hidden"></div>
<div id="status-text" class="status-text"></div>
<div id="result-container" class="result-container hidden">
<a id="result-link" href="#" target="_blank" class="result-link">https://...</a>
<button id="btn-copy-result" class="btn btn-sm">Copy Link</button>
</div>
</div>
</div>
<div class="footer">
<span>f0ckm Integration v2.0</span>
</div>
</body>
</html>

View File

@@ -0,0 +1,184 @@
function initPopup() {
const modeDesktop = document.getElementById("mode-desktop");
const modeDirect = document.getElementById("mode-direct");
const btnUploadTab = document.getElementById("btn-upload-tab");
const btnUploadText = document.getElementById("btn-upload-text");
const btnUploadUrl = document.getElementById("btn-upload-url");
const urlInput = document.getElementById("url-input");
const statusCard = document.getElementById("status-card");
const statusSpinner = document.getElementById("status-spinner");
const statusText = document.getElementById("status-text");
const resultContainer = document.getElementById("result-container");
const resultLink = document.getElementById("result-link");
const btnCopyResult = document.getElementById("btn-copy-result");
const siteBadge = document.getElementById("site-badge");
const tabTitle = document.getElementById("tab-title");
const tabUrlPreview = document.getElementById("tab-url-preview");
let activeTabUrl = "";
// 1. Load settings asynchronously without blocking UI initialization
try {
chrome.storage.sync.get({ uploadMode: "desktop" }, (settings) => {
if (chrome.runtime.lastError || !settings) return;
if (settings.uploadMode === "direct") {
if (modeDirect) modeDirect.checked = true;
} else {
if (modeDesktop) modeDesktop.checked = true;
}
});
} catch (e) {}
if (modeDesktop) {
modeDesktop.addEventListener("change", () => {
chrome.storage.sync.set({ uploadMode: "desktop" });
});
}
if (modeDirect) {
modeDirect.addEventListener("change", () => {
chrome.storage.sync.set({ uploadMode: "direct" });
});
}
// 2. Query Tab Info immediately
try {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
if (tabs && tabs[0]) {
populateTabInfo(tabs[0]);
} else {
chrome.tabs.query({ active: true, lastFocusedWindow: true }, (tabs2) => {
if (tabs2 && tabs2[0]) {
populateTabInfo(tabs2[0]);
}
});
}
});
} catch (e) {}
function populateTabInfo(tab) {
if (!tab) return;
activeTabUrl = tab.url || tab.pendingUrl || "";
if (tabTitle) tabTitle.textContent = tab.title || "Active Page";
if (tabUrlPreview) tabUrlPreview.textContent = activeTabUrl || "No URL available";
const isYouTube = activeTabUrl.includes("youtube.com") || activeTabUrl.includes("youtu.be");
if (isYouTube) {
if (siteBadge) {
siteBadge.textContent = "▶ YouTube";
siteBadge.classList.add("youtube");
}
if (btnUploadText) btnUploadText.textContent = "Upload YouTube Video to f0ckm";
if (btnUploadTab) btnUploadTab.classList.add("youtube-style");
} else {
if (siteBadge) {
siteBadge.textContent = "🌐 Web Page";
siteBadge.classList.remove("youtube");
}
if (btnUploadText) btnUploadText.textContent = "Upload Page URL to f0ckm";
if (btnUploadTab) btnUploadTab.classList.remove("youtube-style");
}
}
// 3. Upload Active Tab Button Click Handler
if (btnUploadTab) {
btnUploadTab.addEventListener("click", () => {
if (activeTabUrl && (activeTabUrl.startsWith("http://") || activeTabUrl.startsWith("https://"))) {
triggerUpload(activeTabUrl);
} else {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs ? tabs[0] : null;
const url = tab ? (tab.url || tab.pendingUrl) : "";
if (url && (url.startsWith("http://") || url.startsWith("https://"))) {
activeTabUrl = url;
triggerUpload(url);
} else {
showStatus("Could not get active tab URL.", false);
}
});
}
});
}
// 4. Custom pasted URL Upload Button Click Handler
if (btnUploadUrl) {
btnUploadUrl.addEventListener("click", () => {
const url = urlInput ? urlInput.value.trim() : "";
if (url) {
triggerUpload(url);
} else {
showStatus("Please enter a valid URL.", false);
}
});
}
if (urlInput) {
urlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter" && btnUploadUrl) {
btnUploadUrl.click();
}
});
}
if (btnCopyResult) {
btnCopyResult.addEventListener("click", () => {
const linkUrl = resultLink ? resultLink.href : "";
if (linkUrl && linkUrl !== "#" && !linkUrl.endsWith("#")) {
navigator.clipboard.writeText(linkUrl);
btnCopyResult.textContent = "Copied!";
setTimeout(() => {
btnCopyResult.textContent = "Copy Link";
}, 1500);
}
});
}
function showStatus(text, loading = false) {
if (!statusCard) return;
statusCard.classList.remove("hidden");
if (statusText) statusText.textContent = text;
if (loading) {
if (statusSpinner) statusSpinner.classList.remove("hidden");
if (resultContainer) resultContainer.classList.add("hidden");
} else {
if (statusSpinner) statusSpinner.classList.add("hidden");
}
}
function showSuccessResult(finalUrl) {
if (!statusCard) return;
statusCard.classList.remove("hidden");
if (statusSpinner) statusSpinner.classList.add("hidden");
if (statusText) statusText.textContent = "Upload Successful! Click link to open:";
if (resultLink) {
resultLink.href = finalUrl;
resultLink.textContent = finalUrl;
}
if (resultContainer) resultContainer.classList.remove("hidden");
}
function triggerUpload(url) {
const currentMode = (modeDirect && modeDirect.checked) ? "direct" : "desktop";
if (currentMode === "desktop") {
showStatus("Sending URL to f0ckm desktop app...", true);
chrome.runtime.sendMessage({ action: "upload_url", url: url }, () => {
showStatus("URL sent to f0ckm app! Check system tray / notifications.", false);
});
} else {
showStatus("Sending URL to f0ckm API...", true);
chrome.runtime.sendMessage({ action: "upload_url", url: url }, (response) => {
if (response && response.resultUrl) {
showSuccessResult(response.resultUrl);
} else {
showStatus("URL Upload queued! Link copied to clipboard.", false);
}
});
}
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPopup);
} else {
initPopup();
}

View File

@@ -3,11 +3,12 @@ Type=Application
Name=f0ckm Uploader
GenericName=Image & Media Uploader
Comment=System tray GUI uploader for f0ckm
Exec=__EXEC_PATH__
Exec=__EXEC_PATH__ %u
Icon=__ICON_PATH__
Terminal=false
Categories=Utility;Network;
Keywords=upload;screenshot;f0ckm;tray;
MimeType=x-scheme-handler/f0ckm;x-scheme-handler/f0ckm-uploader;
StartupNotify=false
Actions=capture;

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())

View File

@@ -86,6 +86,7 @@ sed -e "s|__EXEC_PATH__|$INSTALL_DIR/f0ckm-uploader-gui|g" -e "s|__ICON_PATH__|$
chmod +x "$APP_DIR/f0ckm-uploader-gui.desktop"
update-desktop-database "$APP_DIR" &> /dev/null
xdg-mime default f0ckm-uploader-gui.desktop x-scheme-handler/f0ckm x-scheme-handler/f0ckm-uploader &> /dev/null || true
gtk-update-icon-cache -f -t "$HOME/.local/share/icons/hicolor" &> /dev/null || true
kbuildsycoca6 --noincremental &> /dev/null || kbuildsycoca5 --noincremental &> /dev/null || true