12 Commits
Author SHA1 Message Date
andwati 59b18e69a8 Bump version to 1.0.6 2026-08-12 15:21:35 +03:00
andwati 816ae5797a Publish a key-stripped zip for Chrome Web Store uploads
The Web Store dashboard rejects any manifest containing a "key" field
("key field is not allowed in manifest"), but extension/manifest.json
pins one so unpacked/dev installs get a stable ID. Add a third release
asset with key stripped from a copy, so future releases have an
upload-ready zip without touching the pinned manifest.
2026-08-12 15:21:13 +03:00
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 599 additions and 100 deletions
+38 -2
View File
@@ -16,12 +16,48 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Package extension - 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. Keeps the pinned
# "key" field, so unpacked loads get the stable, predictable ID.
run: | run: |
cd extension cd extension
zip -r "../kget-capture-${GITHUB_REF_NAME}.zip" . zip -r "../kget-capture-extension-${GITHUB_REF_NAME}.zip" .
- name: Package extension for Web Store upload
# The Chrome Web Store dashboard rejects any manifest containing a
# "key" field ("key field is not allowed in manifest") -- it assigns
# its own permanent ID on first publish instead. Strip it from a
# copy so this zip is upload-ready; the other extension zip above
# keeps "key" for stable unpacked/dev IDs.
run: |
rm -rf /tmp/store-extension
cp -r extension /tmp/store-extension
python3 -c "
import json
path = '/tmp/store-extension/manifest.json'
with open(path) as f:
manifest = json.load(f)
manifest.pop('key', None)
with open(path, 'w') as f:
json.dump(manifest, f, indent=2)
f.write('\n')
"
cd /tmp/store-extension
zip -r "$OLDPWD/kget-capture-store-${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 - name: Create release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2
with: with:
files: kget-capture-${{ github.ref_name }}.zip files: |
kget-capture-${{ github.ref_name }}.zip
kget-capture-extension-${{ github.ref_name }}.zip
kget-capture-store-${{ github.ref_name }}.zip
generate_release_notes: true generate_release_notes: true
+127 -56
View File
@@ -1,39 +1,99 @@
# KGet Capture # KGet Capture
A Chrome (and other Chromium-browser) extension that sends downloads to A Chromium browser extension that sends downloads to
[KGet](https://apps.kde.org/kget/), KDE's download manager, instead of (or in [KGet](https://apps.kde.org/kget/), KDE's download manager, instead of the
addition to) the browser's built-in downloader. browser's built-in downloader.
- Right-click any link, image, video, or audio element and choose - Right-click a link/image/video/audio and choose **"Download with KGet"**.
**"Download with KGet"** to open KGet's "New Download" dialog, prefilled - Or toggle **auto-capture** from the toolbar so every download is handed off
with the URL — edit the filename or destination folder if you want, then automatically, with the correct filename.
confirm to start the transfer. - A desktop notification fires when a capture finishes downloading.
- 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 ## Requirements
- Linux with KGet installed (and running, or set to autostart) - Linux with KGet installed and running (or set to autostart)
- One of: Google Chrome, Chromium, Brave, Vivaldi, Microsoft Edge - A Chromium-based browser: Chrome, Chromium, Brave, Vivaldi, or Edge
- `python3` and `dbus-send` on `PATH` (standard on virtually all Linux/KDE - `python3`, `dbus-send`
systems already) - `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 ## 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 ```bash
git clone <this-repo-url> kget-capture git clone https://github.com/andwati/kget-capture.git
cd kget-capture cd kget-capture
./install.sh ./install.sh
``` ```
`install.sh` registers a native messaging host for every supported browser it (Or, for an already-tagged version without cloning: download and extract
finds installed, then prints the extension's fixed ID and next steps: `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). Then in `chrome://extensions`: enable **Developer mode**, click **Load
2. Enable **Developer mode** (top-right toggle). unpacked**, and select the `extension/` directory (from your clone, or the
3. Click **Load unpacked** and select this repo's `extension/` directory. extracted extension zip). `install.sh` prints the extension's fixed ID so
4. Confirm the loaded extension's ID matches the one `install.sh` printed. you can confirm it loaded correctly.
## Usage ## Usage
@@ -47,45 +107,51 @@ finds installed, then prints the extension's fixed ID and next steps:
./uninstall.sh ./uninstall.sh
``` ```
This removes the native messaging host registrations and the installed host Removes the native messaging host registrations and installed host script.
script. You'll still need to remove the unpacked extension yourself via Remove the extension itself via `chrome://extensions` (or the Chrome Web
`chrome://extensions` — that step can't be scripted. Store listing); that step can't be scripted.
## Releases ## Releases
Pushing a tag matching `v*.*.*` (e.g. `v1.0.0`) triggers a GitHub Actions Prebuilt releases are published on the
workflow ([.github/workflows/release.yml](.github/workflows/release.yml)) [Releases page](https://github.com/andwati/kget-capture/releases); each
that zips `extension/` and publishes it as a GitHub Release with `vX.Y.Z` tag push builds and publishes one automatically, as three zips:
auto-generated release notes. Bump `"version"` in `extension/manifest.json`
before tagging so the packaged extension's version matches.
```bash - `kget-capture-vX.Y.Z.zip`: full source, for `install.sh`/`install-remote.sh`.
git tag v1.0.0 - `kget-capture-extension-vX.Y.Z.zip`: just the extension, manifest at the
git push origin v1.0.0 zip root, `key` field intact, for manual "Load unpacked" installs.
``` - `kget-capture-store-vX.Y.Z.zip`: same extension, but with `key` stripped,
for uploading to the Chrome Web Store dashboard. The Store rejects any
manifest containing `key` ("key field is not allowed in manifest") since
it assigns its own permanent ID on first publish, which won't match the
pinned ID (`keys/extension-id.txt`) the other zips use. After the first
successful Store publish, update `native-host/com.kget_capture.host.json`'s
`allowed_origins` to include the Store-assigned ID alongside the pinned
one, so both installs can reach the native host.
## Known limitations ## Known limitations
- **`blob:`/`data:` URLs can't be captured.** There's no real network URL to - **`blob:`/`data:`/`file:`/`filesystem:` URLs can't be captured.** There's
hand to KGet, so these downloads proceed normally through Chrome (a console no real network URL to hand to KGet, so these proceed normally through
warning is logged in the extension's service worker). Chrome.
- **Auto-capture has a brief head start.** Manifest V3 removed blocking - **Auto-capture has a brief head start.** Manifest V3 removed blocking
`webRequest`, so Chrome begins the download before the extension can cancel `webRequest`, so Chrome begins the download before the extension can cancel
it. A small/partial temp file may transiently appear before it's erased. 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 - **No editable dialog.** Captures start immediately with no chance to
`showNewTransferDialog` D-Bus method, which opens the interactive "New rename the file or pick a folder beforehand: KGet only skips its
Download" dialog rather than starting the transfer immediately. This is interactive dialog when given a destination path up front, so instant
intentional — it lets you rename the file or change the destination folder capture and an editable prompt are mutually exclusive.
before it starts. If you'd rather it start immediately without a prompt, - **Chrome may still show its own "Save As" dialog** if you skipped step 3 of
see [Troubleshooting](#troubleshooting) for the alternate `addTransfer` Install above.
call — note that route only returns once the dialog is dismissed, so it's - **KGet must already be running.** If it's not, captures fail silently;
not what the extension uses by default. check the service worker console for the actual error (see
- **KGet must already be running.** If it's not, captures fail silently from Troubleshooting).
the user's perspective — check the service worker console for the actual
error (see Troubleshooting).
## 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 - **Check native host registration:** confirm
`~/.config/<browser>/NativeMessagingHosts/com.kget_capture.host.json` exists `~/.config/<browser>/NativeMessagingHosts/com.kget_capture.host.json` exists
and its `path` points at an executable file and its `path` points at an executable file
@@ -95,16 +161,21 @@ git push origin v1.0.0
- **Test D-Bus directly:** - **Test D-Bus directly:**
```bash ```bash
dbus-send --print-reply --dest=org.kde.kget /KGet \ dbus-send --print-reply --dest=org.kde.kget /KGet \
org.kde.kget.main.showNewTransferDialog array:string:"https://example.com/file.zip" 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. 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
``` ```
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 ## License
MIT — see [LICENSE](LICENSE). MIT, see [LICENSE](LICENSE).
+70 -16
View File
@@ -11,16 +11,32 @@ async function refreshBadge() {
updateBadge(autoCaptureEnabled); updateBadge(autoCaptureEnabled);
} }
function sendToKGet(url) { // KGet is a network download manager -- it can't "download" a URL that's
chrome.runtime.sendNativeMessage(NATIVE_HOST, { action: "addTransfer", url }, (response) => { // already local (or embedded in the page), so don't hand it these schemes.
if (chrome.runtime.lastError) { // file: shows up when saving a page you navigated to directly (Ctrl+S on a
console.error("[kget-capture] native host error:", chrome.runtime.lastError.message); // file:// URL is still a "download" as far as chrome.downloads is
return; // 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) => { chrome.runtime.onInstalled.addListener(async (details) => {
@@ -36,20 +52,56 @@ chrome.runtime.onInstalled.addListener(async (details) => {
refreshBadge(); 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) => { chrome.contextMenus.onClicked.addListener((info) => {
if (info.menuItemId !== MENU_ID) return; if (info.menuItemId !== MENU_ID) return;
const url = info.linkUrl || info.srcUrl; const url = info.linkUrl || info.srcUrl;
if (!url) return; 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 }); const { autoCaptureEnabled } = await chrome.storage.local.get({ autoCaptureEnabled: true });
if (!autoCaptureEnabled) return; if (!autoCaptureEnabled && !isContextMenuCapture) return;
const url = item.finalUrl || item.url; const url = item.finalUrl || item.url;
if (url.startsWith("blob:") || url.startsWith("data:")) { if (!isCapturableUrl(url)) {
console.warn("[kget-capture] cannot redirect blob:/data: URL, letting Chrome handle it:", url); console.warn("[kget-capture] cannot hand a local/embedded URL to KGet, letting Chrome handle it:", url);
return; return;
} }
@@ -67,8 +119,10 @@ chrome.downloads.onCreated.addListener(async (item) => {
} catch (e) { } catch (e) {
console.error("[kget-capture] failed to erase Chrome download entry:", 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) => { chrome.storage.onChanged.addListener((changes, area) => {
if (area === "local" && "autoCaptureEnabled" in changes) { if (area === "local" && "autoCaptureEnabled" in changes) {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"manifest_version": 3, "manifest_version": 3,
"name": "KGet Capture", "name": "KGet Capture",
"version": "1.0.0", "version": "1.0.6",
"description": "Send browser downloads to KGet (KDE Download Manager) on Linux.", "description": "Send browser downloads to KGet (KDE Download Manager) on Linux.",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtM1nFg5pPd3UBzynUJHj6aUy7bjudZaX9AQTWGMqAX3fXylw1lubSzP1/BaS9ivvgOJg/loyEgzEZlcNPzblFVlg8BqEJ3tntdk2OzYvUs0pUdcPzzyjso9TykCkUUTI7ev8JkreW/FYlinfvNwSYN4/c1s3h8/I6XHtxOdmO1G3bS98+BQaXPldbDmxoKwyWR8u1f8gTO2Mg3GY4BHau3RWf1kgtNzh20p9wE3sEFdWkFu7ArgRCIrWLh6m3O6lolnHQSCWPwtOTx893Qkp9Eu6FwUnoWc8rsYx8sqa0eNCTCS6Kjr1Iu3yEJ5y9390jUiDZTQ/c62ueCTQaYv2bwIDAQAB", "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtM1nFg5pPd3UBzynUJHj6aUy7bjudZaX9AQTWGMqAX3fXylw1lubSzP1/BaS9ivvgOJg/loyEgzEZlcNPzblFVlg8BqEJ3tntdk2OzYvUs0pUdcPzzyjso9TykCkUUTI7ev8JkreW/FYlinfvNwSYN4/c1s3h8/I6XHtxOdmO1G3bS98+BQaXPldbDmxoKwyWR8u1f8gTO2Mg3GY4BHau3RWf1kgtNzh20p9wE3sEFdWkFu7ArgRCIrWLh6m3O6lolnHQSCWPwtOTx893Qkp9Eu6FwUnoWc8rsYx8sqa0eNCTCS6Kjr1Iu3yEJ5y9390jUiDZTQ/c62ueCTQaYv2bwIDAQAB",
"permissions": ["downloads", "contextMenus", "storage", "nativeMessaging"], "permissions": ["downloads", "contextMenus", "storage", "nativeMessaging"],
+45 -1
View File
@@ -1,5 +1,5 @@
body { body {
width: 240px; width: 320px;
padding: 12px; padding: 12px;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
font-size: 13px; font-size: 13px;
@@ -27,3 +27,47 @@ h1 {
color: #777; color: #777;
line-height: 1.4; 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> </head>
<body> <body>
<h1>KGet Capture</h1> <h1>KGet Capture</h1>
<label class="toggle-row">
<input type="checkbox" id="auto-capture-toggle"> <div id="setup-needed" hidden>
<span>Auto-capture all downloads</span> <p class="hint">One-time setup needed: KGet Capture needs a small local
</label> helper installed before it can talk to KGet. Run this in a terminal:</p>
<p class="status" id="status-line"></p> <div class="cmd-row">
<p class="hint">Right-click any link, image, video, or audio and choose <code id="setup-command"></code>
"Download with KGet" to send it to KGet on demand.</p> <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> <script src="popup.js"></script>
</body> </body>
</html> </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 checkbox = document.getElementById("auto-capture-toggle");
const statusLine = document.getElementById("status-line"); const statusLine = document.getElementById("status-line");
const copyFeedback = document.getElementById("copy-feedback");
function renderStatus(enabled) { function renderStatus(enabled) {
statusLine.textContent = `Auto-capture: ${enabled ? "ON" : "OFF"}`; statusLine.textContent = `Auto-capture: ${enabled ? "ON" : "OFF"}`;
} }
chrome.storage.local.get({ autoCaptureEnabled: true }).then(({ autoCaptureEnabled }) => { function showReady() {
checkbox.checked = autoCaptureEnabled; setupPanel.hidden = true;
renderStatus(autoCaptureEnabled); 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", () => { checkbox.addEventListener("change", () => {
chrome.storage.local.set({ autoCaptureEnabled: checkbox.checked }); chrome.storage.local.set({ autoCaptureEnabled: checkbox.checked });
renderStatus(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 done
fi 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..." echo "Checking KGet's default download folder..."
python3 - <<'PYEOF' python3 - <<'PYEOF'
import configparser import configparser
@@ -90,6 +108,27 @@ else:
print("(If KGet is currently running, restart it for this to take effect.)") print("(If KGet is currently running, restart it for this to take effect.)")
PYEOF 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") EXT_ID=$(cat "$SCRIPT_DIR/keys/extension-id.txt")
cat <<EOF cat <<EOF
@@ -106,3 +145,15 @@ Next steps:
Make sure KGet is running (or set to autostart) for captures to work. Make sure KGet is running (or set to autostart) for captures to work.
EOF 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.""" """Native messaging host bridging Chrome to KGet's D-Bus service."""
import json import json
import os
import re
import shutil
import struct import struct
import subprocess import subprocess
import sys 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(): def read_message():
raw_length = sys.stdin.buffer.read(4) raw_length = sys.stdin.buffer.read(4)
if len(raw_length) < 4: if len(raw_length) < 4:
@@ -23,41 +42,135 @@ def write_message(message):
sys.stdout.buffer.flush() sys.stdout.buffer.flush()
def add_transfer(url): def add_transfer(url, filename):
# showNewTransferDialog opens KGet's "New Download" dialog (editable name # A non-empty destDir makes KGet add+start the transfer immediately with
# and destination) and returns immediately without waiting for the user # no interactive dialog. Passing dir+filename (when we know the real
# to interact with it. addTransfer, by contrast, only returns once the # filename, e.g. from Chrome's own Content-Disposition resolution) also
# dialog is dismissed when destDir is empty -- it blocks the D-Bus call # sidesteps KGet's own filename guess, which is derived purely from the
# on human input, which is why we don't use it here. # 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 = [ cmd = [
"dbus-send", "dbus-send",
"--print-reply", "--print-reply",
"--reply-timeout=10000", "--reply-timeout=10000",
"--dest=org.kde.kget", "--dest=org.kde.kget",
"/KGet", "/KGet",
"org.kde.kget.main.showNewTransferDialog", "org.kde.kget.main.addTransfer",
f"array:string:{url}", f"string:{url}",
f"string:{dest}",
"boolean:true",
] ]
try: try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) result = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except FileNotFoundError: 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: 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: if result.returncode != 0:
stderr = result.stderr.strip() stderr = result.stderr.strip()
return False, f"dbus-send failed: {stderr or 'unknown error'}" return False, f"dbus-send failed: {stderr or 'unknown error'}", None, dest
return True, None
# 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(): def main():
message = read_message() 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"}) write_message({"ok": False, "error": "invalid request"})
return 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}) write_message({"ok": True} if ok else {"ok": False, "error": error})