3 Commits
Author SHA1 Message Date
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
andwati c395c5fbe2 Bump version to 1.0.1 2026-08-12 10:01:26 +03:00
andwati 64c3a55df4 Get correct filenames, skip KGet's dialog, and add a finished notification
Switch auto-capture from onCreated to onDeterminingFilename, which
fires once Chrome has already resolved the real filename (from
Content-Disposition or the URL). That name is now passed through to
the native host and used to build a full dir+filename destination for
addTransfer, replacing showNewTransferDialog. A non-empty destDir
makes KGet skip its interactive dialog and start immediately, which
also fixes it showing an opaque URL path segment as the filename for
signed/obfuscated URLs (e.g. cloud storage links) instead of the real
name.

Also enable KGet's existing (but disabled-by-default) "Download
Finished" KNotify popup via install.sh, so there's still feedback now
that captures no longer show an interactive dialog.
2026-08-12 09:46:54 +03:00
8 changed files with 262 additions and 48 deletions
+52 -21
View File
@@ -5,12 +5,17 @@ A Chrome (and other Chromium-browser) extension that sends downloads to
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.
**"Download with KGet"** to send it straight to KGet — it starts
downloading immediately into your Downloads folder, no dialog.
- 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.
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.
- 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
@@ -18,6 +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)
- `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
@@ -34,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
@@ -72,14 +89,21 @@ git push origin v1.0.0
- **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.
- **No editable dialog.** Captures start immediately with no chance to
rename the file or pick a different folder beforehand — rename/move it
afterward in KGet or your file manager if needed. This is intentional: KGet
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** 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).
@@ -95,15 +119,22 @@ git push origin v1.0.0
- **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
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-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
+30 -13
View File
@@ -11,16 +11,20 @@ async function refreshBadge() {
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;
function sendToKGet(url, filename) {
chrome.runtime.sendNativeMessage(
NATIVE_HOST,
{ action: "addTransfer", url, filename: filename || null },
(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);
}
}
if (!response || !response.ok) {
console.error("[kget-capture] KGet reported failure:", response && response.error);
}
});
);
}
chrome.runtime.onInstalled.addListener(async (details) => {
@@ -40,10 +44,21 @@ chrome.contextMenus.onClicked.addListener((info) => {
if (info.menuItemId !== MENU_ID) return;
const url = info.linkUrl || info.srcUrl;
if (!url) return;
sendToKGet(url);
sendToKGet(url, null);
});
chrome.downloads.onCreated.addListener(async (item) => {
// onDeterminingFilename (rather than onCreated) fires once Chrome has
// already resolved the real filename (from Content-Disposition or the URL),
// which is exactly what we need to hand KGet the correct name instead of an
// opaque URL path segment.
chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
// We're about to cancel this download regardless, so Chrome's own choice
// doesn't matter -- just let it proceed so the event resolves promptly.
suggest();
captureDownload(item);
});
async function captureDownload(item) {
const { autoCaptureEnabled } = await chrome.storage.local.get({ autoCaptureEnabled: true });
if (!autoCaptureEnabled) return;
@@ -67,8 +82,10 @@ chrome.downloads.onCreated.addListener(async (item) => {
} catch (e) {
console.error("[kget-capture] failed to erase Chrome download entry:", e);
}
sendToKGet(url);
});
const filename = item.filename ? item.filename.split(/[\\/]/).pop() : null;
sendToKGet(url, filename);
}
chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && "autoCaptureEnabled" in changes) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "KGet Capture",
"version": "1.0.0",
"version": "1.0.2",
"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" });
});
+51
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,6 +108,27 @@ else:
print("(If KGet is currently running, restart it for this to take effect.)")
PYEOF
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?) -- 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")
cat <<EOF
@@ -106,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
+111 -13
View File
@@ -2,11 +2,30 @@
"""Native messaging host bridging Chrome to KGet's D-Bus service."""
import json
import os
import re
import shutil
import struct
import subprocess
import sys
def default_download_dir():
home = os.path.expanduser("~")
try:
result = subprocess.run(
["xdg-user-dir", "DOWNLOAD"], capture_output=True, text=True, timeout=5
)
downloads = result.stdout.strip()
except (FileNotFoundError, subprocess.TimeoutExpired):
downloads = ""
# xdg-user-dir falls back to bare $HOME (not $HOME/Downloads) when
# there's no ~/.config/user-dirs.dirs at all -- treat that as not found.
if not downloads or downloads.rstrip("/") == home.rstrip("/"):
return os.path.join(home, "Downloads")
return downloads
def read_message():
raw_length = sys.stdin.buffer.read(4)
if len(raw_length) < 4:
@@ -23,32 +42,109 @@ def write_message(message):
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.
def add_transfer(url, filename):
# A non-empty destDir makes KGet add+start the transfer immediately with
# no interactive dialog. Passing dir+filename (when we know the real
# filename, e.g. from Chrome's own Content-Disposition resolution) also
# 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
cmd = [
"dbus-send",
"--print-reply",
"--reply-timeout=10000",
"--dest=org.kde.kget",
"/KGet",
"org.kde.kget.main.showNewTransferDialog",
f"array:string:{url}",
"org.kde.kget.main.addTransfer",
f"string:{url}",
f"string:{dest}",
"boolean:true",
]
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():
@@ -57,7 +153,9 @@ def main():
write_message({"ok": False, "error": "invalid request"})
return
ok, error = add_transfer(message["url"])
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})