10 Commits
Author SHA1 Message Date
andwati 79b72494db Bump version to 1.0.5 2026-08-12 15:10:33 +03:00
andwati 08744915c4 Add a curl/wget one-line installer and popup setup detection
Add install-remote.sh, which fetches the latest release and runs its
install.sh so setup no longer needs a git clone. The popup now probes
the native host with a "ping" action on open and, if it's not
registered yet, shows the one-liner (with copy-to-clipboard) instead
of the auto-capture toggle -- so a Web Store install can be completed
end-to-end by clicking the extension icon and pasting one command.
2026-08-12 15:07:55 +03:00
andwati bd24586113 Publish separate extension-only zip alongside source archive
Chrome requires manifest.json at the zip root for Web Store submission
and manual unpacked loading. The whole-repo git archive added for the
native-host installer buried it under extension/, so publish both: a
manifest-at-root extension zip and the full-repo source zip.
2026-08-12 14:54:58 +03:00
andwati 6ef2133760 Refactor README for distro install docs, fix release archive
Trim intro fluff, add per-distro dependency install commands, and make
the Chrome Web Store the primary extension install path with unpacked
install demoted to a dev-only section. Also switch the release workflow
to archive the whole repo instead of just extension/, since install.sh
and native-host/ are now what the README points users at downloading.
2026-08-12 14:50:12 +03:00
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
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
10 changed files with 564 additions and 101 deletions
+14 -2
View File
@@ -16,12 +16,24 @@ jobs:
uses: actions/checkout@v4
- name: Package extension
# Chrome requires manifest.json at the zip root, both for Web Store
# upload and for manually loading unpacked -- so this one archives
# only extension/'s contents, not the whole repo.
run: |
cd extension
zip -r "../kget-capture-${GITHUB_REF_NAME}.zip" .
zip -r "../kget-capture-extension-${GITHUB_REF_NAME}.zip" .
- name: Package source
# Separately, the native-host installer bundle (install.sh,
# native-host/, keys/) needs the whole repo. git archive only
# includes tracked files, so the gitignored private key (never
# committed) can't end up in it.
run: git archive --format=zip -o "kget-capture-${GITHUB_REF_NAME}.zip" HEAD
- name: Create release
uses: softprops/action-gh-release@v2
with:
files: kget-capture-${{ github.ref_name }}.zip
files: |
kget-capture-${{ github.ref_name }}.zip
kget-capture-extension-${{ github.ref_name }}.zip
generate_release_notes: true
+116 -57
View File
@@ -1,39 +1,99 @@
# 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.
A Chromium browser extension that sends downloads to
[KGet](https://apps.kde.org/kget/), KDE's download manager, instead of 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.
- Right-click a link/image/video/audio and choose **"Download with KGet"**.
- Or toggle **auto-capture** from the toolbar so every download is handed off
automatically, with the correct filename.
- A desktop notification fires when a capture finishes downloading.
## 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)
- Linux with KGet installed and running (or set to autostart)
- A Chromium-based browser: Chrome, Chromium, Brave, Vivaldi, or Edge
- `python3`, `dbus-send`
- `curl` or `wget`, for the one-line remote installer below (skip if you'd
rather download and run `install.sh` yourself)
- `notify-send`, optional, for the completion notification
- `kwriteconfig6` (KDE Frameworks), optional, silences KGet's own duplicate
notification
Install everything on your distro:
**Fedora**
```bash
sudo dnf install kget python3 dbus-tools libnotify kf6-kconfig
```
**Arch / Manjaro**
```bash
sudo pacman -S kget python dbus libnotify kconfig
```
**Debian / Ubuntu / KDE neon**
```bash
sudo apt install kget python3 dbus-bin libnotify-bin kf6-kconfig-bin
```
**openSUSE**
```bash
sudo zypper install kget python3 dbus-1 libnotify-tools kconfig-tools
```
(`notify-send` and `kwriteconfig6` package names shift around between distro
versions. If a command above fails, drop that one package and search your
package manager for the binary name instead. Everything else still works
without them.)
## Install
1. Install the extension from the Chrome Web Store: **`<CHROME_WEB_STORE_URL>`**
*(placeholder, fill in once the listing is live)*.
2. Click the extension's toolbar icon. The popup detects that the native
messaging host isn't set up yet and shows a one-line command; run it in a
terminal, then click "I ran it, check again" in the popup:
```bash
curl -fsSL https://raw.githubusercontent.com/andwati/kget-capture/main/install-remote.sh | bash
```
(or with `wget`: `wget -qO- https://raw.githubusercontent.com/andwati/kget-capture/main/install-remote.sh | bash`)
This downloads the latest release and runs its `install.sh`, which
registers the native host for every supported browser it finds installed
and prints any remaining setup warnings. No git clone required. If you'd
rather run it yourself: download and extract `kget-capture-vX.Y.Z.zip`
(the source archive, not `kget-capture-extension-vX.Y.Z.zip`) from the
[Releases page](https://github.com/andwati/kget-capture/releases), then
from inside that folder run `./install.sh` directly.
3. Open your browser's download settings (`chrome://settings/downloads`,
`brave://settings/downloads`, etc.) and turn **off** "Ask where to save
each file before downloading". Chrome shows this dialog itself, before the
extension gets a chance to intercept the download, so this one-time change
is required (no extension API can do it for you).
### Building from source
Contributing, or want to run an unreleased version? Load the extension
unpacked instead of step 1 above:
```bash
git clone <this-repo-url> kget-capture
git clone https://github.com/andwati/kget-capture.git
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:
(Or, for an already-tagged version without cloning: download and extract
`kget-capture-extension-vX.Y.Z.zip` from the
[Releases page](https://github.com/andwati/kget-capture/releases) instead.
It's already just `extension/`'s contents, unpacked at the zip root the way
Chrome expects. You'll still need `kget-capture-vX.Y.Z.zip` for `install.sh`,
per step 2 above.)
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.
Then in `chrome://extensions`: enable **Developer mode**, click **Load
unpacked**, and select the `extension/` directory (from your clone, or the
extracted extension zip). `install.sh` prints the extension's fixed ID so
you can confirm it loaded correctly.
## Usage
@@ -47,45 +107,39 @@ finds installed, then prints the extension's fixed ID and next steps:
./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.
Removes the native messaging host registrations and installed host script.
Remove the extension itself via `chrome://extensions` (or the Chrome Web
Store listing); that step can't be scripted.
## Releases
Pushing a tag matching `v*.*.*` (e.g. `v1.0.0`) triggers a GitHub Actions
workflow ([.github/workflows/release.yml](.github/workflows/release.yml))
that zips `extension/` and publishes it as a GitHub Release with
auto-generated release notes. Bump `"version"` in `extension/manifest.json`
before tagging so the packaged extension's version matches.
```bash
git tag v1.0.0
git push origin v1.0.0
```
Prebuilt releases are published on the
[Releases page](https://github.com/andwati/kget-capture/releases); each
`vX.Y.Z` tag push builds and publishes one automatically.
## 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, so these proceed normally through
Chrome.
- **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).
- **No editable dialog.** Captures start immediately with no chance to
rename the file or pick a folder beforehand: KGet only skips its
interactive dialog when given a destination path up front, so instant
capture and an editable prompt are mutually exclusive.
- **Chrome may still show its own "Save As" dialog** if you skipped step 3 of
Install above.
- **KGet must already be running.** If it's not, captures fail silently;
check the service worker console for the actual error (see
Troubleshooting).
## Troubleshooting
- **`install-remote.sh` fails to look up the latest release?** It queries
GitHub's API, which rate-limits unauthenticated requests (60/hour per IP);
wait a bit, or fall back to the manual `install.sh` steps in Install above.
- **Check native host registration:** confirm
`~/.config/<browser>/NativeMessagingHosts/com.kget_capture.host.json` exists
and its `path` points at an executable file
@@ -95,16 +149,21 @@ 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. This notification
is separate from KGet's own built-in "Download Finished" event.
`install.sh` disables that one 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
MIT — see [LICENSE](LICENSE).
MIT, see [LICENSE](LICENSE).
+70 -16
View File
@@ -11,16 +11,32 @@ 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;
// 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,
{ 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) => {
@@ -36,20 +52,56 @@ 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);
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);
});
});
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 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;
}
@@ -67,8 +119,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.5",
"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"],
+45 -1
View File
@@ -1,5 +1,5 @@
body {
width: 240px;
width: 320px;
padding: 12px;
font-family: system-ui, sans-serif;
font-size: 13px;
@@ -27,3 +27,47 @@ 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;
}
.cmd-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
}
.cmd-row code {
flex: 1;
min-width: 0;
overflow-x: auto;
white-space: nowrap;
background: #f0f0f0;
padding: 4px 6px;
border-radius: 3px;
font-size: 11px;
}
.cmd-row button {
flex: none;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
#recheck-setup {
display: block;
margin-top: 10px;
padding: 4px 8px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
+26 -7
View File
@@ -6,13 +6,32 @@
</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>
<div id="setup-needed" hidden>
<p class="hint">One-time setup needed: KGet Capture needs a small local
helper installed before it can talk to KGet. Run this in a terminal:</p>
<div class="cmd-row">
<code id="setup-command"></code>
<button id="copy-command" type="button">Copy</button>
</div>
<p class="status" id="copy-feedback"></p>
<button id="recheck-setup" type="button">I ran it, check again</button>
</div>
<div id="ready" hidden>
<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>
<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>
</div>
<script src="popup.js"></script>
</body>
</html>
+54 -3
View File
@@ -1,16 +1,67 @@
const NATIVE_HOST = "com.kget_capture.host";
const INSTALL_COMMAND =
"curl -fsSL https://raw.githubusercontent.com/andwati/kget-capture/main/install-remote.sh | bash";
const setupPanel = document.getElementById("setup-needed");
const readyPanel = document.getElementById("ready");
const checkbox = document.getElementById("auto-capture-toggle");
const statusLine = document.getElementById("status-line");
const copyFeedback = document.getElementById("copy-feedback");
function renderStatus(enabled) {
statusLine.textContent = `Auto-capture: ${enabled ? "ON" : "OFF"}`;
}
chrome.storage.local.get({ autoCaptureEnabled: true }).then(({ autoCaptureEnabled }) => {
checkbox.checked = autoCaptureEnabled;
renderStatus(autoCaptureEnabled);
function showReady() {
setupPanel.hidden = true;
readyPanel.hidden = false;
chrome.storage.local.get({ autoCaptureEnabled: true }).then(({ autoCaptureEnabled }) => {
checkbox.checked = autoCaptureEnabled;
renderStatus(autoCaptureEnabled);
});
}
function showSetupNeeded() {
document.getElementById("setup-command").textContent = INSTALL_COMMAND;
readyPanel.hidden = true;
setupPanel.hidden = false;
}
// The native host is only reachable once install.sh (or install-remote.sh)
// has registered it -- sendNativeMessage fails with a lastError like
// "Specified native messaging host not found" until then, which is exactly
// the signal we want to gate the rest of the popup on.
function checkNativeHost() {
chrome.runtime.sendNativeMessage(NATIVE_HOST, { action: "ping" }, () => {
if (chrome.runtime.lastError) {
showSetupNeeded();
} else {
showReady();
}
});
}
checkNativeHost();
document.getElementById("recheck-setup").addEventListener("click", checkNativeHost);
document.getElementById("copy-command").addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(INSTALL_COMMAND);
copyFeedback.textContent = "Copied to clipboard.";
} catch (e) {
copyFeedback.textContent = "Couldn't copy -- select and copy the command manually.";
}
setTimeout(() => {
copyFeedback.textContent = "";
}, 2000);
});
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" });
});
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Fetches the latest release source archive and runs install.sh from it, so
# setup doesn't require a git clone:
#
# curl -fsSL https://raw.githubusercontent.com/andwati/kget-capture/main/install-remote.sh | bash
# wget -qO- https://raw.githubusercontent.com/andwati/kget-capture/main/install-remote.sh | bash
set -euo pipefail
REPO="andwati/kget-capture"
fetch_stdout() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1"
elif command -v wget >/dev/null 2>&1; then
wget -qO- "$1"
else
echo "ERROR: this installer needs 'curl' or 'wget' on PATH." >&2
exit 1
fi
}
fetch_to_file() {
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$1" -o "$2"
else
wget -qO "$2" "$1"
fi
}
echo "Looking up the latest release..."
tag="$(fetch_stdout "https://api.github.com/repos/$REPO/releases/latest" \
| grep -m1 '"tag_name"' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/')"
if [ -z "$tag" ]; then
echo "ERROR: couldn't determine the latest release tag from GitHub." >&2
exit 1
fi
echo "Latest release: $tag"
if ! command -v python3 >/dev/null 2>&1; then
echo "ERROR: 'python3' is required but not found on PATH." >&2
exit 1
fi
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
zip_path="$tmp_dir/kget-capture.zip"
zip_url="https://github.com/$REPO/releases/download/$tag/kget-capture-$tag.zip"
echo "Downloading $zip_url..."
fetch_to_file "$zip_url" "$zip_path"
echo "Extracting..."
src_dir="$tmp_dir/src"
python3 -c "
import sys, zipfile
zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])
" "$zip_path" "$src_dir"
echo "Running install.sh..."
bash "$src_dir/install.sh"
+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
+127 -14
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,41 +42,135 @@ 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()
# 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",
"--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():
message = read_message()
if not message or message.get("action") != "addTransfer" or "url" not in message:
if not message:
write_message({"ok": False, "error": "invalid request"})
return
ok, error = add_transfer(message["url"])
# The extension popup uses this to detect whether the native host is
# installed/registered at all, distinct from whether a transfer succeeds.
if message.get("action") == "ping":
write_message({"ok": True})
return
if message.get("action") != "addTransfer" or "url" not in message:
write_message({"ok": False, "error": "invalid request"})
return
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})