Reviving the KDE Connect browser extension for manifest v3
TL;DR: I revived the dead KDE Connect browser extension by porting it to manifest v3 and fixing the native host. Code is on GitHub, and it is installable from the Chrome Web Store.
I send links from my browser to my phone with KDE Connect, and the browser half of that is a small extension backed by a Go native messaging host. This year that extension died three separate deaths at once.
It is pdf/kdeconnect-chrome-extension by Peter Fern, MIT licensed, last shipped as v0.1.7 in March 2022. Nothing upstream was wrong with it. Everything around it moved:
- Chrome finished removing manifest v2, so the extension simply stopped running.
- KDE Connect 22.04 renamed its D-Bus API, so the host stopped working even where MV2 survived: every share came back as "Device is not trusted".
- The Chrome Web Store listing was removed, so there was nothing left to install.
So I forked it, ported it, and put it back on the store. The interesting parts were the two deaths, because they broke the same program in completely different ways.
The host died of a rename
The extension needs a native messaging host, a Go binary that reads JSON messages on stdin and talks to the KDE Connect daemon over D-Bus. In KDE Connect 22.04 the daemon renamed the device property isTrusted to isPaired, and replaced the trustedChanged/stateChanged signals with pairStateChanged.
A renamed property sounds harmless. It is not, because of how the host read it:
v, err := d.obj.GetProperty(dest + `.device.isTrusted`)
if err != nil {
return err
}
Against a current daemon the lookup fails, the error propagates, and IsTrusted stays at its zero value: false. The device list still renders. The device looks paired. Every single share is rejected with "Device is not trusted". A missing property does not crash anything, it just silently produces the wrong answer, which is the worst failure mode a daemon rename can have.
The fix reads the new name with the old one as a fallback, so the same binary works against both generations:
// kdeconnect >= v22.04 renamed isTrusted to isPaired
v, err := d.obj.GetProperty(dest + `.device.isPaired`)
if err != nil {
if v, err = d.obj.GetProperty(dest + `.device.isTrusted`); err != nil {
return err
}
}
d.IsTrusted = v.Value().(bool)
Signals got the same treatment: subscribe to both trustedChanged and pairStateChanged, handle both in the switch.
And while in there I found an older bug that had been sleeping since 2020. The host subscribed to reachableChanged (the name since KDE Connect 1.2) but the handler switch only matched reachableStatusChanged, the pre-1.2 name. The match rule was correct, the signal arrived, and nothing ever acted on it. Reachability only ever updated when some other signal happened to fire. Both names are handled now.
The build side needed its own grave-turning: the project used glide, a dependency manager that has been dead for years. It is now on go modules and builds unchanged under go 1.26.
The extension died of a page that cannot exist
Manifest v3 has no persistent background page. There is a service worker instead, and Chrome kills it after roughly 30 seconds of idle. The old background.js was written as if none of that were true:
- Device state and badge state lived in module-level variables, assumed to survive forever.
- A timer-driven reconnect loop with exponential back-off kept the native host connection alive.
- Context menu items were registered with per-item
onclickhandlers. - Timers were
window.setTimeout, and a worker has nowindow.
State moves to storage.session
Variables do not survive worker termination, so knownDevices and badges now live in storage.session, which survives worker restarts and dies with the browser session. Exactly the lifetime device state should have. Options stay in storage.sync as before.
One gate, every listener funnels through it
The reconnect loop is the part I had to think hardest about, because a terminated worker cannot honour timers. There is no loop to keep running. So instead of reconnecting on a schedule, the worker now reconnects on demand, through a single lazily-created promise:
// ready resolves once this worker instance has its state back and a live
// connection to the native host.
function ready() {
if (readyPromise === null) {
readyPromise = Promise.all([restoreSession(), restoreOptions()]).then(connect);
}
return readyPromise;
}
Every entry point, onMessage, onInstalled, onStartup, the context menu click, the storage change listener, awaits ready() first. When the native port disconnects, onDisconnect sets readyPromise = null, and the next event that wakes the worker rebuilds everything. Events are the only clock a service worker has, so events became the retry mechanism.
A nice side effect: when the popup asks for devices, the worker answers from the session cache immediately and lets the host refresh follow. The popup renders instantly instead of waiting on D-Bus.
Smaller v3 fallout
contextMenus.createno longer acceptsonclick. All items are created bare and onecontextMenus.onClickedlistener dispatches oninfo.menuItemId.browserActionbecameaction,_execute_browser_actionbecame_execute_action,chrome_styleis gone.window.setTimeoutis a ReferenceError in a worker. PlainsetTimeoutexists.- The popup and options pages keep the worker alive while open, with a
runtime.connect({ name: 'kdeconnect-ui' })port. A live port pins the worker, so device updates keep flowing to an open popup.
Two manifests, one source tree
Chrome's MV3 only accepts service_worker for the background. Firefox's MV3 has no service worker background and still wants background.scripts as an event page. One manifest cannot satisfy both, so manifest.firefox.json lives alongside manifest.json and a 23-line build-extension.sh assembles dist/chrome and dist/firefox plus the store zips. Everything else, all the JS, is shared verbatim.
The sender check that ate the device list
The popup and options pages identify messages coming from the background by checking sender.url against the background's URL. In MV2 that was background.html. After the port it became js/background.js on Chrome.
On Firefox it became neither. Firefox wraps background scripts in a generated page, so sender.url is moz-extension://.../_generated_background_page.html. The sender check matched nothing, every device list was discarded on arrival, and the popup showed no devices while the host was answering perfectly. The data was fine. The doorman threw it out.
The check now accepts both URLs. There was also a notfound/notFound typo in the popup that threw a ReferenceError whenever zero devices were found, which is precisely when you need the "No devices found" message.
Shipping it
Upstream's Web Store listing is gone, so the fork has its own now, and the installer's default extension ID points at it, since the old ID resolves to nothing. The host is v0.2.0; the protocol version is still 0.1.3 because the wire format never moved. The Firefox listing still lives upstream on Mozilla Add-ons and works with this host.
One small build lesson: the first version of the build script opened with rm -rf dist, which happily wiped host release tarballs staged in the same directory. It now removes only the exact files it produces. Scripts that clean should only ever clean what they own.
Code is on GitHub, and it is installable from the Chrome Web Store. Peter Fern's MIT license and copyright are unchanged. The extension had been dead for years, and now it shares again.
The first month back on the store
A month ago, I brought the KDE Connect browser extension back to the Chrome Web Store. Here’s how its first month went.
From 5 September to 4 October 2026, the listing received 602 store impressions and 245 page views. It recorded 165 installs and 85 uninstalls, a net gain of 80 installs, with 86 weekly users. Each headline metric was unchanged compared with the previous 30-day period.
The United States accounted for the largest share of installs (32%), while Linux was the leading install platform (36%). Weekly users were concentrated in Sweden (48%) and the United States (15%); Windows (64%) and Linux (32%) made up most users with operating-system data shown. Most page views came through the Chrome extension sidebar (62%).
The extension is finding users across platforms, and installs continue to outnumber removals. Store impressions and weekly users were flat over the period, so the next challenge is turning that steady visibility into more active users.