4 Commits
Author SHA1 Message Date
andwati 6ed564f802 Bump version to 1.0.4 2026-08-12 14:36:25 +03:00
andwati a46226a0db Fix right-click download landing in home folder with wrong filename
Route context-menu captures through chrome.downloads so they resolve
Chrome's real filename (Content-Disposition, extension, etc.) via the
same onDeterminingFilename path auto-capture already uses, and harden
the native host against the bare-directory destDir KGet was misparsing
as a filename.
2026-08-12 14:36:17 +03:00
andwati a2cd4184e3 Don't hand file:// URLs to KGet
Ctrl+S on a page you navigated to directly via file:// is still a
"download" as far as chrome.downloads is concerned, so auto-capture
was intercepting it and handing the local path to KGet, which
rejects it with a confusing "protocol is not supported" dialog since
it's a network download manager, not a file copier. Extend the
existing blob:/data: exclusion to also cover file:/filesystem:, and
apply the same guard to the right-click capture path.
2026-08-12 12:11:03 +03:00
andwati 235c377361 Fix Chrome save-as prompt and unreliable finish notifications
Chrome's own "Ask where to save each file" setting shows a native
picker before the extension can ever intercept the download, and no
extension API can read or suppress it -- surface this clearly in
install.sh, the README, and a popup shortcut instead of leaving it
buried as a footnote.

Also fixes silently missing "download complete" notifications: KGet
only fires its own finished-notification if a transfer was observed
in a non-finished state at least once, so small/fast downloads that
complete before that first observation never get one. The native
host now drives its own notification via notify-send, polling actual
transfer progress instead of relying on KGet's internal check, and
install.sh disables KGet's own notification to avoid duplicates.
2026-08-12 11:33:46 +03:00
8 changed files with 232 additions and 44 deletions
+37 -25
View File
@@ -11,8 +11,11 @@ addition to) the browser's built-in downloader.
starts is cancelled and handed off to KGet the same way, automatically,
with the correct filename (resolved by Chrome itself, so it's right even
for URLs whose path is just an opaque ID). Defaults to ON.
- KGet's own "Download Finished" desktop notification is enabled on install,
so you still get feedback once a capture completes.
- A "Download complete" desktop notification appears once a capture actually
finishes downloading. This is driven by the extension itself (not KGet's
own per-transfer notification, which KGet silently skips for downloads
that finish very quickly — see Troubleshooting), so it fires reliably
regardless of file size or speed.
## Requirements
@@ -20,8 +23,12 @@ addition to) the browser's built-in downloader.
- One of: Google Chrome, Chromium, Brave, Vivaldi, Microsoft Edge
- `python3` and `dbus-send` on `PATH` (standard on virtually all Linux/KDE
systems already)
- `kwriteconfig6` (ships with KDE Plasma) — optional, only used to enable the
download-finished notification; install still works without it
- `notify-send` (ships with most Linux desktops via libnotify) — optional,
only used for the download-complete notification; captures still work
without it, just silently
- `kwriteconfig6` (ships with KDE Plasma) — optional, only used to disable
KGet's own unreliable per-transfer notification so it doesn't double up
with the extension's; install still works without it
## Install
@@ -38,6 +45,12 @@ finds installed, then prints the extension's fixed ID and next steps:
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.
5. Open `chrome://settings/downloads` (or your browser's equivalent, e.g.
`brave://settings/downloads`, `edge://settings/downloads`) and turn **off**
"Ask where to save each file before downloading". Chrome shows this dialog
itself, before the extension ever gets a chance to intercept the download —
no extension API can read or suppress it, so this one-time setting change
is required for captures to work without an extra prompt.
## Usage
@@ -70,9 +83,11 @@ git push origin v1.0.0
## 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).
- **`blob:`/`data:`/`file:`/`filesystem:` URLs can't be captured.** There's
no real network URL to hand to KGet — this includes saving a page you
navigated to directly via `file://` (Ctrl+S on it is still a "download" as
far as Chrome is concerned) — 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.
@@ -82,19 +97,8 @@ git push origin v1.0.0
only skips its interactive "New Download" dialog when given a real
destination path up front, so instant capture and an editable prompt are
mutually exclusive given KGet's D-Bus API.
- **The context-menu capture can't always get the correct filename.**
Auto-capture gets the real filename for free from Chrome's own
`Content-Disposition` resolution. Right-click captures don't have that —
the extension only has the raw URL — so KGet falls back to guessing from
the URL path, which is wrong for opaque/signed URLs (e.g. cloud storage
links). Fixing this would require broad host permissions and an extra HTTP
request per right-click, which isn't worth it for now.
- **Chrome may still show its own "Save As" dialog.** This is controlled by
Chrome's own **Ask where to save each file before downloading** setting
(`chrome://settings/downloads`) — no extension API can read or suppress
it, and if it's on, Chrome shows the picker *before* our extension gets a
chance to see the download at all. Turn that setting off for auto-capture
to work without an extra prompt.
- **Chrome may still show its own "Save As" dialog** if you skipped step 5 of
Install above — see there for why and how to fix it.
- **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).
@@ -113,11 +117,19 @@ git push origin v1.0.0
org.kde.kget.main.addTransfer string:"https://example.com/file.zip" string:"$HOME/Downloads/file.zip" boolean:true
```
If this fails with `ServiceUnknown`, KGet isn't running.
- **Download-finished notification not showing?** `install.sh` enables it via
`kwriteconfig6 --file knotifyrc --group "Event/kget/finished" --key Action Popup`.
Check System Settings → Notifications → Applications → "KGet Download
Manager" to confirm it's on, or disable it the same way with
`kwriteconfig6 --file knotifyrc --group "Event/kget/finished" --key Action None`.
- **Download-complete notification not showing?** Confirm `notify-send` is
installed (`command -v notify-send`) — without it, `kget_capture_host.py`
skips the notification entirely rather than erroring. Otherwise, check that
the transfer actually reached 100%: `kget_capture_host.py` polls
`org.kde.kget.transfer.percent` on the new transfer's D-Bus object path
(e.g. `/KGet/Transfers/9`, printed by the `addTransfer` call above) until it
hits 100, so a stuck/erroring transfer never fires one. Note this
notification is separate from KGet's own built-in "Download Finished"
event — `install.sh` disables that one (`kwriteconfig6 --file knotifyrc
--group "Event/kget/finished" --key Action None`) because KGet only fires
it for transfers it observed in a non-finished state at least once, so it
silently skips small/fast downloads that complete before that first
observation.
## License
+41 -4
View File
@@ -11,6 +11,18 @@ async function refreshBadge() {
updateBadge(autoCaptureEnabled);
}
// KGet is a network download manager -- it can't "download" a URL that's
// already local (or embedded in the page), so don't hand it these schemes.
// file: shows up when saving a page you navigated to directly (Ctrl+S on a
// file:// URL is still a "download" as far as chrome.downloads is
// concerned), and KGet's addTransfer rejects it with a confusing
// "protocol is not supported" dialog if we try.
const UNSUPPORTED_SCHEMES = ["blob:", "data:", "file:", "filesystem:"];
function isCapturableUrl(url) {
return !UNSUPPORTED_SCHEMES.some((scheme) => url.startsWith(scheme));
}
function sendToKGet(url, filename) {
chrome.runtime.sendNativeMessage(
NATIVE_HOST,
@@ -40,11 +52,35 @@ chrome.runtime.onInstalled.addListener(async (details) => {
refreshBadge();
});
// Downloads started from the context menu, tracked so captureDownload()
// still resolves/forwards their real filename even when auto-capture is
// toggled off -- a right-click "Download with KGet" should always work.
const pendingContextMenuDownloads = new Set();
chrome.contextMenus.onClicked.addListener((info) => {
if (info.menuItemId !== MENU_ID) return;
const url = info.linkUrl || info.srcUrl;
if (!url) return;
sendToKGet(url, null);
if (!isCapturableUrl(url)) {
console.warn("[kget-capture] cannot hand a local/embedded URL to KGet:", url);
return;
}
// Route through chrome.downloads instead of sending the raw URL straight
// to KGet -- this reuses onDeterminingFilename/captureDownload below to
// get Chrome's real resolved filename (Content-Disposition, etc.) instead
// of leaving KGet to guess one from the bare URL, which drops query
// strings and gets opaque/signed URLs wrong.
chrome.downloads.download({ url }, (downloadId) => {
if (chrome.runtime.lastError || downloadId === undefined) {
console.error(
"[kget-capture] couldn't start download to resolve filename, falling back to raw URL:",
chrome.runtime.lastError && chrome.runtime.lastError.message
);
sendToKGet(url, null);
return;
}
pendingContextMenuDownloads.add(downloadId);
});
});
// onDeterminingFilename (rather than onCreated) fires once Chrome has
@@ -59,12 +95,13 @@ chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
});
async function captureDownload(item) {
const isContextMenuCapture = pendingContextMenuDownloads.delete(item.id);
const { autoCaptureEnabled } = await chrome.storage.local.get({ autoCaptureEnabled: true });
if (!autoCaptureEnabled) return;
if (!autoCaptureEnabled && !isContextMenuCapture) 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);
if (!isCapturableUrl(url)) {
console.warn("[kget-capture] cannot hand a local/embedded URL to KGet, letting Chrome handle it:", url);
return;
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KGet Capture",
"version": "1.0.1",
"version": "1.0.4",
"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"],
+9
View File
@@ -27,3 +27,12 @@ h1 {
color: #777;
line-height: 1.4;
}
.hint button {
display: block;
margin-top: 6px;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
+4
View File
@@ -13,6 +13,10 @@
<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>
<p class="hint">Downloads still prompting for a location? Chrome shows that
dialog itself, before this extension can intercept anything.
<button id="open-download-settings" type="button">Turn off Chrome's download prompt</button>
</p>
<script src="popup.js"></script>
</body>
</html>
+4
View File
@@ -14,3 +14,7 @@ checkbox.addEventListener("change", () => {
chrome.storage.local.set({ autoCaptureEnabled: checkbox.checked });
renderStatus(checkbox.checked);
});
document.getElementById("open-download-settings").addEventListener("click", () => {
chrome.tabs.create({ url: "chrome://settings/downloads" });
});
+48 -8
View File
@@ -51,6 +51,24 @@ if [ "$registered_any" = false ]; then
done
fi
declare -A BROWSER_SETTINGS_URLS=(
["Google Chrome"]="chrome://settings/downloads"
["Chromium"]="chrome://settings/downloads"
["Brave"]="brave://settings/downloads"
["Vivaldi"]="vivaldi://settings/downloads"
["Microsoft Edge"]="edge://settings/downloads"
)
echo "Checking Chrome's \"Ask where to save each file\" setting..."
prompt_warnings=()
for browser in "${!BROWSER_DIRS[@]}"; do
config_dir="${BROWSER_DIRS[$browser]}"
prefs_file="$config_dir/Default/Preferences"
if [ -f "$prefs_file" ] && grep -q '"prompt_for_download":true' "$prefs_file" 2>/dev/null; then
prompt_warnings+=(" - $browser: open ${BROWSER_SETTINGS_URLS[$browser]} and turn off \"Ask where to save each file before downloading\"")
fi
done
echo "Checking KGet's default download folder..."
python3 - <<'PYEOF'
import configparser
@@ -90,15 +108,25 @@ else:
print("(If KGet is currently running, restart it for this to take effect.)")
PYEOF
echo "Checking KGet's download-finished notification..."
KNOTIFYRC="$HOME/.config/knotifyrc"
if [ -f "$KNOTIFYRC" ] && grep -q '^\[Event/kget/finished\]' "$KNOTIFYRC"; then
echo "KGet's download-finished notification is already configured, leaving it as-is"
elif command -v kwriteconfig6 >/dev/null 2>&1; then
kwriteconfig6 --file knotifyrc --group "Event/kget/finished" --key Action Popup
echo "Enabled a desktop popup notification for KGet's \"Download Finished\" event"
echo "Disabling KGet's own per-transfer notification..."
# KGet only fires this event if the transfer was still non-Finished the
# first time its UI observed it -- small/fast downloads routinely finish
# before that, so KGet silently skips it for them. kget_capture_host.py
# shows its own "Download complete" notification (via notify-send) for
# every capture instead, driven by polling actual transfer progress, so it
# fires reliably regardless of download speed. Disable KGet's copy here to
# avoid a duplicate popup on the transfers that are slow enough for it to
# fire on its own.
if command -v kwriteconfig6 >/dev/null 2>&1; then
kwriteconfig6 --file knotifyrc --group "Event/kget/finished" --key Action None
echo "Done -- KGet Capture's own notification will be used instead"
else
echo "Skipped: 'kwriteconfig6' not found (not on KDE Plasma?) -- no popup notification set up"
echo "Skipped: 'kwriteconfig6' not found (not on KDE Plasma?) -- KGet's own notification, if any, is left as configured"
fi
echo "Checking for 'notify-send' (used for the download-complete notification)..."
if ! command -v notify-send >/dev/null 2>&1; then
echo "WARNING: 'notify-send' not found -- captures will still work, but you won't get a notification when they finish"
fi
EXT_ID=$(cat "$SCRIPT_DIR/keys/extension-id.txt")
@@ -117,3 +145,15 @@ Next steps:
Make sure KGet is running (or set to autostart) for captures to work.
EOF
if [ "${#prompt_warnings[@]}" -gt 0 ]; then
cat <<EOF
WARNING: "Ask where to save each file before downloading" is ON for:
$(printf '%s\n' "${prompt_warnings[@]}")
Chrome shows that dialog itself, before the extension ever gets a chance to
intercept the download -- no extension API can read or suppress it. Turn it
off in each browser above or captures will keep prompting for a location.
EOF
fi
+88 -6
View File
@@ -3,6 +3,8 @@
import json
import os
import re
import shutil
import struct
import subprocess
import sys
@@ -47,7 +49,12 @@ def add_transfer(url, filename):
# sidesteps KGet's own filename guess, which is derived purely from the
# URL path and gets it wrong for opaque/signed URLs.
dest_dir = default_download_dir()
dest = os.path.join(dest_dir, filename) if filename else dest_dir
# A bare directory path with no trailing slash (e.g. "/home/ian/Downloads")
# gets misparsed by KGet's addTransfer as dir="/home/ian" + filename=
# "Downloads" -- the same split it applies to a real dir/filename path --
# landing the transfer in the parent directory instead. A trailing slash
# keeps it unambiguous when we don't have a real filename to append.
dest = os.path.join(dest_dir, filename) if filename else dest_dir.rstrip("/") + "/"
cmd = [
"dbus-send",
"--print-reply",
@@ -62,14 +69,87 @@ def add_transfer(url, filename):
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except FileNotFoundError:
return False, "dbus-send not found on this system"
return False, "dbus-send not found on this system", None, dest
except subprocess.TimeoutExpired:
return False, "dbus-send call timed out (is KGet running?)"
return False, "dbus-send call timed out (is KGet running?)", None, dest
if result.returncode != 0:
stderr = result.stderr.strip()
return False, f"dbus-send failed: {stderr or 'unknown error'}"
return True, None
return False, f"dbus-send failed: {stderr or 'unknown error'}", None, dest
# addTransfer replies with an array of the new transfer's D-Bus object
# path(s), e.g. `array [ string "/KGet/Transfers/9" ]` -- grab the first
# one so we can poll it for completion (see spawn_notifier).
match = re.search(r'string "(/KGet/Transfers/\d+)"', result.stdout)
transfer_path = match.group(1) if match else None
return True, None, transfer_path, dest
# KGet only fires its own "Download Finished" desktop notification if the
# transfer was still in a non-Finished state the first time KGet's UI
# observed it (see GenericObserver::transfersChangedEvent in KGet's
# core/kget.cpp: `status() == Finished && startStatus() != Finished`).
# Small/fast downloads routinely finish before that first observation, so
# KGet silently skips the notification for them -- confirmed by tracing
# actual D-Bus traffic against a running KGet instance. We replace it with
# our own, driven by polling the transfer's real progress, so captures
# always get a completion notification regardless of how fast they finish.
_NOTIFIER_SCRIPT = """
import subprocess, sys, time
path, dest = sys.argv[1], sys.argv[2]
def get_percent():
try:
result = subprocess.run(
["dbus-send", "--session", "--print-reply", "--dest=org.kde.kget",
path, "org.kde.kget.transfer.percent"],
capture_output=True, text=True, timeout=10,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
if result.returncode != 0:
return None
for line in result.stdout.splitlines():
line = line.strip()
if line.startswith("int32"):
return int(line.split()[-1])
return None
deadline = time.time() + 6 * 3600
finished = False
while time.time() < deadline:
percent = get_percent()
if percent is None:
break # transfer object is gone: finished+cleared, cancelled, or KGet restarted
if percent >= 100:
finished = True
break
time.sleep(2)
if finished:
subprocess.run(
["notify-send", "--app-name=KGet Capture", "--icon=kget",
"Download complete", dest.rsplit("/", 1)[-1]],
timeout=10,
)
"""
def spawn_notifier(transfer_path, dest):
if not transfer_path or not shutil.which("notify-send"):
return
# Detached (new session, no inherited pipes) so it keeps running after
# Chrome closes the native-messaging connection and kills this process --
# sendNativeMessage() tears down its native host as soon as it gets a
# reply, which is long before a real download finishes.
subprocess.Popen(
[sys.executable, "-c", _NOTIFIER_SCRIPT, transfer_path, dest],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
def main():
@@ -78,7 +158,9 @@ def main():
write_message({"ok": False, "error": "invalid request"})
return
ok, error = add_transfer(message["url"], message.get("filename"))
ok, error, transfer_path, dest = add_transfer(message["url"], message.get("filename"))
if ok:
spawn_notifier(transfer_path, dest)
write_message({"ok": True} if ok else {"ok": False, "error": error})