commit ef70a2009346335cc0e5037b066a16c7f2521c5d Author: andwati Date: Wed Aug 12 09:02:09 2026 +0300 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19a240c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +keys/*.pem +__pycache__/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b95ca61 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Ian + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9abbd0f --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# KGet Capture + +A Chrome (and other Chromium-browser) extension that sends downloads to +[KGet](https://apps.kde.org/kget/), KDE's download manager, instead of (or in +addition to) the browser's built-in downloader. + +- Right-click any link, image, video, or audio element and choose + **"Download with KGet"** to open KGet's "New Download" dialog, prefilled + with the URL — edit the filename or destination folder if you want, then + confirm to start the transfer. +- A toolbar toggle enables **auto-capture**: while on, every download Chrome + starts is cancelled and redirected to the same KGet dialog automatically. + Defaults to ON. + +## Requirements + +- Linux with KGet installed (and running, or set to autostart) +- One of: Google Chrome, Chromium, Brave, Vivaldi, Microsoft Edge +- `python3` and `dbus-send` on `PATH` (standard on virtually all Linux/KDE + systems already) + +## Install + +```bash +git clone kget-capture +cd kget-capture +./install.sh +``` + +`install.sh` registers a native messaging host for every supported browser it +finds installed, then prints the extension's fixed ID and next steps: + +1. Open `chrome://extensions` (or your browser's equivalent). +2. Enable **Developer mode** (top-right toggle). +3. Click **Load unpacked** and select this repo's `extension/` directory. +4. Confirm the loaded extension's ID matches the one `install.sh` printed. + +## Usage + +- **On-demand:** right-click a link/image/video/audio → "Download with KGet". +- **Automatic:** click the toolbar icon and flip "Auto-capture all downloads" + on or off. The badge shows "ON" (green) or "OFF" (grey). + +## Uninstall + +```bash +./uninstall.sh +``` + +This removes the native messaging host registrations and the installed host +script. You'll still need to remove the unpacked extension yourself via +`chrome://extensions` — that step can't be scripted. + +## Known limitations + +- **`blob:`/`data:` URLs can't be captured.** There's no real network URL to + hand to KGet, so these downloads proceed normally through Chrome (a console + warning is logged in the extension's service worker). +- **Auto-capture has a brief head start.** Manifest V3 removed blocking + `webRequest`, so Chrome begins the download before the extension can cancel + it. A small/partial temp file may transiently appear before it's erased. +- **A KGet dialog opens for every capture.** The extension uses KGet's + `showNewTransferDialog` D-Bus method, which opens the interactive "New + Download" dialog rather than starting the transfer immediately. This is + intentional — it lets you rename the file or change the destination folder + before it starts. If you'd rather it start immediately without a prompt, + see [Troubleshooting](#troubleshooting) for the alternate `addTransfer` + call — note that route only returns once the dialog is dismissed, so it's + not what the extension uses by default. +- **KGet must already be running.** If it's not, captures fail silently from + the user's perspective — check the service worker console for the actual + error (see Troubleshooting). + +## Troubleshooting + +- **Check native host registration:** confirm + `~/.config//NativeMessagingHosts/com.kget_capture.host.json` exists + and its `path` points at an executable file + (`~/.local/share/kget-capture/kget_capture_host.py`). +- **Inspect the extension's logs:** on `chrome://extensions`, find "KGet + Capture", click "service worker" under "Inspect views" to open its console. +- **Test D-Bus directly:** + ```bash + dbus-send --print-reply --dest=org.kde.kget /KGet \ + org.kde.kget.main.showNewTransferDialog array:string:"https://example.com/file.zip" + ``` + If this fails with `ServiceUnknown`, KGet isn't running. To skip the dialog + and start a transfer straight away instead, use `addTransfer` with a real + destination folder: + ```bash + dbus-send --print-reply --dest=org.kde.kget /KGet \ + org.kde.kget.main.addTransfer string:"https://example.com/file.zip" string:"$HOME/Downloads" boolean:true + ``` + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/extension/background.js b/extension/background.js new file mode 100644 index 0000000..eff3978 --- /dev/null +++ b/extension/background.js @@ -0,0 +1,79 @@ +const NATIVE_HOST = "com.kget_capture.host"; +const MENU_ID = "kget-capture-download"; + +function updateBadge(enabled) { + chrome.action.setBadgeText({ text: enabled ? "ON" : "OFF" }); + chrome.action.setBadgeBackgroundColor({ color: enabled ? "#2e7d32" : "#9e9e9e" }); +} + +async function refreshBadge() { + const { autoCaptureEnabled } = await chrome.storage.local.get({ autoCaptureEnabled: true }); + updateBadge(autoCaptureEnabled); +} + +function sendToKGet(url) { + chrome.runtime.sendNativeMessage(NATIVE_HOST, { action: "addTransfer", url }, (response) => { + if (chrome.runtime.lastError) { + console.error("[kget-capture] native host error:", chrome.runtime.lastError.message); + return; + } + if (!response || !response.ok) { + console.error("[kget-capture] KGet reported failure:", response && response.error); + } + }); +} + +chrome.runtime.onInstalled.addListener(async (details) => { + chrome.contextMenus.create({ + id: MENU_ID, + title: "Download with KGet", + contexts: ["link", "image", "video", "audio"], + }); + + if (details.reason === "install") { + await chrome.storage.local.set({ autoCaptureEnabled: true }); + } + refreshBadge(); +}); + +chrome.contextMenus.onClicked.addListener((info) => { + if (info.menuItemId !== MENU_ID) return; + const url = info.linkUrl || info.srcUrl; + if (!url) return; + sendToKGet(url); +}); + +chrome.downloads.onCreated.addListener(async (item) => { + const { autoCaptureEnabled } = await chrome.storage.local.get({ autoCaptureEnabled: true }); + if (!autoCaptureEnabled) return; + + const url = item.finalUrl || item.url; + if (url.startsWith("blob:") || url.startsWith("data:")) { + console.warn("[kget-capture] cannot redirect blob:/data: URL, letting Chrome handle it:", url); + return; + } + + try { + await chrome.downloads.cancel(item.id); + // erase() only clears the history entry -- it does NOT delete the + // partial file Chrome already wrote to disk. removeFile() does that. + await chrome.downloads.removeFile(item.id); + } catch (e) { + // Expected if Chrome finished the download before cancel() landed, or + // hadn't written any bytes yet -- not worth surfacing to the user. + } + try { + await chrome.downloads.erase({ id: item.id }); + } catch (e) { + console.error("[kget-capture] failed to erase Chrome download entry:", e); + } + sendToKGet(url); +}); + +chrome.storage.onChanged.addListener((changes, area) => { + if (area === "local" && "autoCaptureEnabled" in changes) { + updateBadge(changes.autoCaptureEnabled.newValue); + } +}); + +refreshBadge(); diff --git a/extension/icons/icon128.png b/extension/icons/icon128.png new file mode 100644 index 0000000..dad2f28 Binary files /dev/null and b/extension/icons/icon128.png differ diff --git a/extension/icons/icon16.png b/extension/icons/icon16.png new file mode 100644 index 0000000..e6dd142 Binary files /dev/null and b/extension/icons/icon16.png differ diff --git a/extension/icons/icon32.png b/extension/icons/icon32.png new file mode 100644 index 0000000..2ac1517 Binary files /dev/null and b/extension/icons/icon32.png differ diff --git a/extension/icons/icon48.png b/extension/icons/icon48.png new file mode 100644 index 0000000..c251bd9 Binary files /dev/null and b/extension/icons/icon48.png differ diff --git a/extension/manifest.json b/extension/manifest.json new file mode 100644 index 0000000..0a12fa4 --- /dev/null +++ b/extension/manifest.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "KGet Capture", + "version": "1.0.0", + "description": "Send browser downloads to KGet (KDE Download Manager) on Linux.", + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtM1nFg5pPd3UBzynUJHj6aUy7bjudZaX9AQTWGMqAX3fXylw1lubSzP1/BaS9ivvgOJg/loyEgzEZlcNPzblFVlg8BqEJ3tntdk2OzYvUs0pUdcPzzyjso9TykCkUUTI7ev8JkreW/FYlinfvNwSYN4/c1s3h8/I6XHtxOdmO1G3bS98+BQaXPldbDmxoKwyWR8u1f8gTO2Mg3GY4BHau3RWf1kgtNzh20p9wE3sEFdWkFu7ArgRCIrWLh6m3O6lolnHQSCWPwtOTx893Qkp9Eu6FwUnoWc8rsYx8sqa0eNCTCS6Kjr1Iu3yEJ5y9390jUiDZTQ/c62ueCTQaYv2bwIDAQAB", + "permissions": ["downloads", "contextMenus", "storage", "nativeMessaging"], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon16.png", + "32": "icons/icon32.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } + }, + "icons": { + "16": "icons/icon16.png", + "48": "icons/icon48.png", + "128": "icons/icon128.png" + } +} diff --git a/extension/popup.css b/extension/popup.css new file mode 100644 index 0000000..189d84f --- /dev/null +++ b/extension/popup.css @@ -0,0 +1,29 @@ +body { + width: 240px; + padding: 12px; + font-family: system-ui, sans-serif; + font-size: 13px; +} + +h1 { + font-size: 14px; + margin: 0 0 10px; +} + +.toggle-row { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +.status { + margin: 8px 0 0; + color: #555; +} + +.hint { + margin: 10px 0 0; + color: #777; + line-height: 1.4; +} diff --git a/extension/popup.html b/extension/popup.html new file mode 100644 index 0000000..a9bf6d1 --- /dev/null +++ b/extension/popup.html @@ -0,0 +1,18 @@ + + + + + + + +

KGet Capture

+ +

+

Right-click any link, image, video, or audio and choose + "Download with KGet" to send it to KGet on demand.

+ + + diff --git a/extension/popup.js b/extension/popup.js new file mode 100644 index 0000000..8f2907b --- /dev/null +++ b/extension/popup.js @@ -0,0 +1,16 @@ +const checkbox = document.getElementById("auto-capture-toggle"); +const statusLine = document.getElementById("status-line"); + +function renderStatus(enabled) { + statusLine.textContent = `Auto-capture: ${enabled ? "ON" : "OFF"}`; +} + +chrome.storage.local.get({ autoCaptureEnabled: true }).then(({ autoCaptureEnabled }) => { + checkbox.checked = autoCaptureEnabled; + renderStatus(autoCaptureEnabled); +}); + +checkbox.addEventListener("change", () => { + chrome.storage.local.set({ autoCaptureEnabled: checkbox.checked }); + renderStatus(checkbox.checked); +}); diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..b4c08c5 --- /dev/null +++ b/install.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="$HOME/.local/share/kget-capture" +HOST_SCRIPT_SRC="$SCRIPT_DIR/native-host/kget_capture_host.py" +HOST_SCRIPT_DEST="$INSTALL_DIR/kget_capture_host.py" +MANIFEST_TEMPLATE="$SCRIPT_DIR/native-host/com.kget_capture.host.json" +GENERATED_MANIFEST="$INSTALL_DIR/com.kget_capture.host.json" + +echo "Checking dependencies..." +for dep in python3 dbus-send; do + if ! command -v "$dep" >/dev/null 2>&1; then + echo "ERROR: '$dep' is required but not found on PATH." >&2 + exit 1 + fi +done + +echo "Installing native host script to $HOST_SCRIPT_DEST" +mkdir -p "$INSTALL_DIR" +cp "$HOST_SCRIPT_SRC" "$HOST_SCRIPT_DEST" +chmod +x "$HOST_SCRIPT_DEST" + +echo "Generating native messaging host manifest..." +sed "s|__HOST_SCRIPT_PATH__|$HOST_SCRIPT_DEST|" "$MANIFEST_TEMPLATE" > "$GENERATED_MANIFEST" + +declare -A BROWSER_DIRS=( + ["Google Chrome"]="$HOME/.config/google-chrome" + ["Chromium"]="$HOME/.config/chromium" + ["Brave"]="$HOME/.config/BraveSoftware/Brave-Browser" + ["Vivaldi"]="$HOME/.config/vivaldi" + ["Microsoft Edge"]="$HOME/.config/microsoft-edge" +) + +registered_any=false +for browser in "${!BROWSER_DIRS[@]}"; do + config_dir="${BROWSER_DIRS[$browser]}" + if [ -d "$config_dir" ]; then + target_dir="$config_dir/NativeMessagingHosts" + mkdir -p "$target_dir" + cp "$GENERATED_MANIFEST" "$target_dir/com.kget_capture.host.json" + echo "Registered native host for $browser" + registered_any=true + fi +done + +if [ "$registered_any" = false ]; then + echo "WARNING: no supported browser config directories were found. Checked:" + for browser in "${!BROWSER_DIRS[@]}"; do + echo " - ${BROWSER_DIRS[$browser]}" + done +fi + +EXT_ID=$(cat "$SCRIPT_DIR/keys/extension-id.txt") + +cat < keys/extension-id.txt +``` + +Rotating the key changes the extension ID, which means `extension/manifest.json`'s +`"key"` and `native-host/com.kget_capture.host.json`'s `allowed_origins` both +need updating to match. diff --git a/keys/extension-id.txt b/keys/extension-id.txt new file mode 100644 index 0000000..1be9663 --- /dev/null +++ b/keys/extension-id.txt @@ -0,0 +1 @@ +lgkhlfhejbffjnpcdlpjclicihnbjijp diff --git a/keys/kget-capture-public.b64 b/keys/kget-capture-public.b64 new file mode 100644 index 0000000..b2b0a45 --- /dev/null +++ b/keys/kget-capture-public.b64 @@ -0,0 +1 @@ +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtM1nFg5pPd3UBzynUJHj6aUy7bjudZaX9AQTWGMqAX3fXylw1lubSzP1/BaS9ivvgOJg/loyEgzEZlcNPzblFVlg8BqEJ3tntdk2OzYvUs0pUdcPzzyjso9TykCkUUTI7ev8JkreW/FYlinfvNwSYN4/c1s3h8/I6XHtxOdmO1G3bS98+BQaXPldbDmxoKwyWR8u1f8gTO2Mg3GY4BHau3RWf1kgtNzh20p9wE3sEFdWkFu7ArgRCIrWLh6m3O6lolnHQSCWPwtOTx893Qkp9Eu6FwUnoWc8rsYx8sqa0eNCTCS6Kjr1Iu3yEJ5y9390jUiDZTQ/c62ueCTQaYv2bwIDAQAB \ No newline at end of file diff --git a/keys/kget-capture-public.der b/keys/kget-capture-public.der new file mode 100644 index 0000000..f931ae8 Binary files /dev/null and b/keys/kget-capture-public.der differ diff --git a/native-host/com.kget_capture.host.json b/native-host/com.kget_capture.host.json new file mode 100644 index 0000000..fd87681 --- /dev/null +++ b/native-host/com.kget_capture.host.json @@ -0,0 +1,7 @@ +{ + "name": "com.kget_capture.host", + "description": "Native messaging host that hands off download URLs to KGet via D-Bus", + "path": "__HOST_SCRIPT_PATH__", + "type": "stdio", + "allowed_origins": ["chrome-extension://lgkhlfhejbffjnpcdlpjclicihnbjijp/"] +} diff --git a/native-host/kget_capture_host.py b/native-host/kget_capture_host.py new file mode 100755 index 0000000..d0367ba --- /dev/null +++ b/native-host/kget_capture_host.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Native messaging host bridging Chrome to KGet's D-Bus service.""" + +import json +import struct +import subprocess +import sys + + +def read_message(): + raw_length = sys.stdin.buffer.read(4) + if len(raw_length) < 4: + return None + (length,) = struct.unpack("