initial commit

This commit is contained in:
2026-08-12 09:02:09 +03:00
commit ef70a20093
20 changed files with 495 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
keys/*.pem
__pycache__/
+21
View File
@@ -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.
+97
View File
@@ -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 <this-repo-url> 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/<browser>/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).
+79
View File
@@ -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();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 651 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1018 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+25
View File
@@ -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"
}
}
+29
View File
@@ -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;
}
+18
View File
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="popup.css">
</head>
<body>
<h1>KGet Capture</h1>
<label class="toggle-row">
<input type="checkbox" id="auto-capture-toggle">
<span>Auto-capture all downloads</span>
</label>
<p class="status" id="status-line"></p>
<p class="hint">Right-click any link, image, video, or audio and choose
"Download with KGet" to send it to KGet on demand.</p>
<script src="popup.js"></script>
</body>
</html>
+16
View File
@@ -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);
});
Executable
+69
View File
@@ -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 <<EOF
Install complete.
Extension ID: $EXT_ID
Next steps:
1. Open chrome://extensions (or the equivalent for your browser)
2. Enable "Developer mode" (top-right toggle)
3. Click "Load unpacked" and select: $SCRIPT_DIR/extension
4. Confirm the loaded extension's ID matches: $EXT_ID
Make sure KGet is running (or set to autostart) for captures to work.
EOF
+32
View File
@@ -0,0 +1,32 @@
# Extension signing key
This directory holds the RSA key pair used to pin the extension's Chrome ID so
it's identical across every install (needed so the native messaging host's
`allowed_origins` can be hardcoded instead of asking each user to paste their
extension ID).
- `kget-capture-private.pem` — **not committed** (see `.gitignore`). Keep it
somewhere safe if you want to be able to reproduce the same key deterministically
later; losing it just means a future re-key would get a new extension ID, it's
not a security incident.
- `kget-capture-public.der` / `kget-capture-public.b64` — derived from the
private key. The base64 contents of `kget-capture-public.b64` is pasted
verbatim into `extension/manifest.json`'s `"key"` field. These are already
effectively public once checked into `manifest.json`, so committing them is
fine (and `extension-id.txt` too).
- `extension-id.txt` — the 32-character Chrome extension ID derived from the
public key (also hardcoded into `native-host/com.kget_capture.host.json`).
## Regenerating (only if rotating the key)
```bash
openssl genrsa -out keys/kget-capture-private.pem 2048
openssl rsa -in keys/kget-capture-private.pem -pubout -outform DER -out keys/kget-capture-public.der
openssl base64 -A -in keys/kget-capture-public.der -out keys/kget-capture-public.b64
openssl dgst -sha256 -binary keys/kget-capture-public.der | head -c 16 | od -An -tx1 \
| tr -d ' \n' | tr '0123456789abcdef' 'abcdefghijklmnop' > 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.
+1
View File
@@ -0,0 +1 @@
lgkhlfhejbffjnpcdlpjclicihnbjijp
+1
View File
@@ -0,0 +1 @@
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtM1nFg5pPd3UBzynUJHj6aUy7bjudZaX9AQTWGMqAX3fXylw1lubSzP1/BaS9ivvgOJg/loyEgzEZlcNPzblFVlg8BqEJ3tntdk2OzYvUs0pUdcPzzyjso9TykCkUUTI7ev8JkreW/FYlinfvNwSYN4/c1s3h8/I6XHtxOdmO1G3bS98+BQaXPldbDmxoKwyWR8u1f8gTO2Mg3GY4BHau3RWf1kgtNzh20p9wE3sEFdWkFu7ArgRCIrWLh6m3O6lolnHQSCWPwtOTx893Qkp9Eu6FwUnoWc8rsYx8sqa0eNCTCS6Kjr1Iu3yEJ5y9390jUiDZTQ/c62ueCTQaYv2bwIDAQAB
Binary file not shown.
+7
View File
@@ -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/"]
}
+65
View File
@@ -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("<I", raw_length)
data = sys.stdin.buffer.read(length)
return json.loads(data)
def write_message(message):
data = json.dumps(message).encode("utf-8")
sys.stdout.buffer.write(struct.pack("<I", len(data)))
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
def add_transfer(url):
# showNewTransferDialog opens KGet's "New Download" dialog (editable name
# and destination) and returns immediately without waiting for the user
# to interact with it. addTransfer, by contrast, only returns once the
# dialog is dismissed when destDir is empty -- it blocks the D-Bus call
# on human input, which is why we don't use it here.
cmd = [
"dbus-send",
"--print-reply",
"--reply-timeout=10000",
"--dest=org.kde.kget",
"/KGet",
"org.kde.kget.main.showNewTransferDialog",
f"array:string:{url}",
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except FileNotFoundError:
return False, "dbus-send not found on this system"
except subprocess.TimeoutExpired:
return False, "dbus-send call timed out (is KGet running?)"
if result.returncode != 0:
stderr = result.stderr.strip()
return False, f"dbus-send failed: {stderr or 'unknown error'}"
return True, None
def main():
message = read_message()
if not message or message.get("action") != "addTransfer" or "url" not in message:
write_message({"ok": False, "error": "invalid request"})
return
ok, error = add_transfer(message["url"])
write_message({"ok": True} if ok else {"ok": False, "error": error})
if __name__ == "__main__":
main()
Executable
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
set -euo pipefail
INSTALL_DIR="$HOME/.local/share/kget-capture"
BROWSER_DIRS=(
"$HOME/.config/google-chrome"
"$HOME/.config/chromium"
"$HOME/.config/BraveSoftware/Brave-Browser"
"$HOME/.config/vivaldi"
"$HOME/.config/microsoft-edge"
)
for config_dir in "${BROWSER_DIRS[@]}"; do
manifest="$config_dir/NativeMessagingHosts/com.kget_capture.host.json"
if [ -f "$manifest" ]; then
rm -f "$manifest"
echo "Removed $manifest"
fi
done
if [ -d "$INSTALL_DIR" ]; then
rm -rf "$INSTALL_DIR"
echo "Removed $INSTALL_DIR"
fi
cat <<EOF
Uninstall complete.
Note: this does not remove the unpacked extension itself. Remove it manually
via chrome://extensions (or the equivalent for your browser).
EOF