Skip to content

feat: add Edge/Chromium browser extension support#138

Open
phelix001 wants to merge 5 commits intolinux-credentials:mainfrom
phelix001:feat/edge-chromium-webext
Open

feat: add Edge/Chromium browser extension support#138
phelix001 wants to merge 5 commits intolinux-credentials:mainfrom
phelix001:feat/edge-chromium-webext

Conversation

@phelix001
Copy link

@phelix001 phelix001 commented Feb 16, 2026

Summary

Adds Edge/Chromium (Chrome 111+, Edge 111+) support to the browser extension, unified with the existing Firefox extension into a single codebase.

  • Unified webext/add-on/ with shared JS and browser-specific manifests
  • Both browsers use the same MAIN + ISOLATED world content script architecture

Architecture

Both Firefox and Chromium now share the same content script architecture:

Script World Role
content-main.js MAIN Overrides navigator.credentials.create/get, serializes ArrayBuffers via native Uint8Array.toBase64()/fromBase64()
content-bridge.js ISOLATED Bridges window.postMessage to runtime.connect() for native messaging
background.js Background Forwards messages between content scripts and native messaging host

Browser differences handled via:

  • manifest.firefox.json — background scripts, gecko.strict_min_version: 140.0
  • manifest.chromium.json — service worker, Chrome 111+
  • globalThis.browser || globalThis.chrome for API detection

The Python native messaging host (credential_manager_shim.py) is reused unchanged.

Commits

  1. feat: add Edge/Chromium web extension port — initial port with separate add-on-edge/ directory
  2. refactor: merge Firefox and Chromium add-ons into unified folder — addresses review feedback, eliminates code duplication, removes cloneInto()/exportFunction() in favor of shared MAIN+ISOLATED architecture
  3. docs: update READMEs for unified browser extension — updates README for Chromium support, fixes stale add-on-edge/ references in webext/README

Test plan

  • Load extension in Edge via edge://extensions → "Load unpacked" (copy manifest.chromium.json to manifest.json first)
  • Configure native messaging manifest with extension ID
  • Start credentialsd + credentialsd-ui D-Bus services
  • Test credential registration on https://webauthn.io
  • Test credential assertion on https://webauthn.io
  • Test on Chrome (chrome://extensions) with equivalent setup
  • Verify Firefox extension still works unchanged (copy manifest.firefox.json to manifest.json)

Port the Firefox web extension to Edge/Chromium (MV3, Chrome 111+).

Key architectural differences from Firefox version:
- Two content scripts: MAIN world (overrides navigator.credentials)
  and ISOLATED world (bridges to background via chrome.runtime)
- window.postMessage bridge between MAIN and ISOLATED worlds
  (Firefox uses exportFunction/cloneInto which don't exist in Chromium)
- Base64url encoding via btoa/atob helpers instead of
  Uint8Array.toBase64/fromBase64 (not available in Chromium)
- Service worker background script instead of persistent background page
- chrome.* namespace instead of browser.*

New files:
- webext/add-on-edge/ - Complete Edge/Chromium extension
- webext/app/credential_manager_shim_edge.json.in - Native messaging
  manifest template for Chromium-based browsers

Updated README with Edge/Chromium setup instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Member

@iinuwa iinuwa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello! Thanks for looking into this!

I haven't gotten the chance to test this out yet, but I did an initial look through, and there's quite a bit of duplicated code. We hope not to have to keep this around long term, but I still think it'd be helpful not to duplicate the code.

I think this means that I'd like to see if we can keep all the JavaScript files in the one add-on folder, with different manifests and "utils" files that contain the differences between Firefox and Chromium, and a check at runtime to import the correct one. If that means creating the extra "bridge" port in Firefox and/or a shim of cloneInto() for Chromium even if it's technically unnecessary, then that's fine with me.

Then we'd use Meson to bundle the add-ons for each browser platform.

I can help with the Meson parts; would you be willing to look into merging these two folders together?

Comment on lines +9 to +27
function arrayBufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function base64urlToBytes(str) {
if (!str) return null;
const padded = str.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is built into Chromium as Uint8Array.from/toBase64; can we use those?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — switched to native Uint8Array.toBase64() / fromBase64() with {alphabet: "base64url", omitPadding: true} throughout. The manual btoa/atob helpers are removed entirely.

Comment on lines +10 to +29
// Base64url helpers (Chromium doesn't have Uint8Array.toBase64/fromBase64)
function arrayBufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}

function base64urlToArrayBuffer(str) {
if (!str) return null;
const padded = str.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here; the comment is out of date: Chrome has had these for about 6 months.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — removed the outdated comment and the manual helpers. Using native Uint8Array.toBase64() / fromBase64() here as well.

Address PR review feedback to eliminate code duplication between
webext/add-on/ (Firefox) and webext/add-on-edge/ (Chromium).

Key changes:
- Unified architecture: both browsers now use MAIN + ISOLATED world
  content scripts with window.postMessage bridge, eliminating the
  need for Firefox-specific cloneInto()/exportFunction() APIs
- Use native Uint8Array.toBase64()/fromBase64() for base64url
  encoding/decoding (supported in both Firefox 140+ and Chrome 111+)
- Simplified background.js: ArrayBuffer serialization now happens in
  content-main.js, so background just forwards messages
- Browser-specific manifests: manifest.firefox.json (background
  scripts) and manifest.chromium.json (service worker)
- Browser API detection via globalThis.browser || globalThis.chrome
  in content-bridge.js and background.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phelix001
Copy link
Author

Thanks for the review! I've pushed a commit that addresses all the feedback:

Merged into a single add-on/ folder — deleted add-on-edge/ entirely. Both browsers now share the same JS files:

  • content-main.js (MAIN world) — overrides navigator.credentials, handles all ArrayBuffer serialization
  • content-bridge.js (ISOLATED world) — relays messages between MAIN world and background via window.postMessageruntime.connect
  • background.js — simplified to just forward messages (no more serializeRequest needed since content-main.js handles serialization)

Browser-specific manifests: manifest.firefox.json (background scripts) and manifest.chromium.json (service worker)

Native Uint8Array.toBase64()/fromBase64() used everywhere — all manual btoa/atob helpers removed.

Browser API detection via globalThis.browser || globalThis.chrome in background.js and content-bridge.js.

Firefox now uses the same MAIN + ISOLATED world architecture as Chromium (with the bridge port as you suggested), which eliminates the need for cloneInto()/exportFunction() entirely.

I left a TODO in meson.build for the Chromium build target — happy to take your help on that part.

@phelix001 phelix001 changed the title feat: add Edge/Chromium web extension port feat: add Edge/Chromium browser extension support Feb 24, 2026
@phelix001
Copy link
Author

phelix001 commented Feb 24, 2026

Pushed two new commits on top of the refactor:

docs: update READMEs for unified browser extension (58d581e)

  • Updated main README to document Edge/Chromium support alongside Firefox
  • Fixed webext/README.md — references to the deleted add-on-edge/ directory are gone, and both Firefox and Chromium dev workflows now include the manifest.json copy step (since browsers won't read manifest.firefox.json or manifest.chromium.json directly)
  • Added webext/add-on/manifest.json to .gitignore (generated file from the copy step)

ci: pin Rust toolchain, update actions, add audit and JS checks (612f201)

  • Added rust-toolchain.toml pinning to Rust 1.85 (MSRV for edition 2024 used by credentialsd-common and credentialsd-ui)
  • Updated actions/checkout from v2 to v4
  • Removed manual rustup component add rustfmt clippy — now declared in rust-toolchain.toml
  • Added cargo audit step for dependency vulnerability scanning
  • Added node --check step to validate web extension JS syntax

Happy to split the CI/toolchain changes into a separate PR if you'd prefer to keep this one focused on the web extension work.

- Update README to document Edge/Chromium support alongside Firefox
- Fix webext/README references to deleted add-on-edge/ directory
- Add manifest.json copy step for both Firefox and Chromium dev workflows
- Add webext/add-on/manifest.json to .gitignore (generated for local dev)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@phelix001 phelix001 force-pushed the feat/edge-chromium-webext branch from 1f2f730 to 612f201 Compare February 24, 2026 22:42
@iinuwa
Copy link
Member

iinuwa commented Feb 27, 2026

Thanks for the update! I'm hoping to get to this sometime this weekend.

In the meantime can you remove the CLAUDE.md? We're not ready to declare a policy on AI usage yet.

The unrelated CI changes should also move to a separate PR to make this one easier to review.

@phelix001 phelix001 force-pushed the feat/edge-chromium-webext branch from 612f201 to 58d581e Compare February 27, 2026 21:50
@phelix001
Copy link
Author

Done! Two changes:

  1. CLAUDE.md removed — it was never in the committed files, but I've cleaned up the PR description that referenced it. It won't appear anywhere in this PR.

  2. CI changes split out — the toolchain/actions commit is now in a separate PR: ci: pin Rust toolchain, update actions, add audit and JS checks #143

This PR is now 3 commits, all focused on the web extension work.

@iinuwa ready when you are this weekend.

Copy link
Member

@iinuwa iinuwa left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the long delay in review. I tested and this works for both Chrome and Firefox, thanks! (There a few bugs unrelated to this feature that can make startup inconsistent.)

I have a few more changes to make.

"matches": ["https://webauthn.io/*", "https://demo.yubico.com/*"],
"js": ["content.js"],
"run_at": "document_start"
"matches": ["<all_urls>"],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A change like this will prompt extra review in the Firefox add-ons store, which I'm not ready to do at this time. Can you change this back?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — reverted both content_scripts entries back to the restricted URL list.

"world": "ISOLATED"
},
{
"matches": ["<all_urls>"],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

},

"background": {
"service_worker": "background.js"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only difference between the Firefox manifest and the Chromium manifest is that one uses background.scripts and this uses background.service_worker? (The browser_specific_settings key can be combined as Chrome ignores it silently.)

Can we use the same method in both browsers?

Copy link
Author

@phelix001 phelix001 Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately not — Chrome MV3 requires service_worker and doesn't support background.scripts, so the only unified option would be switching Firefox to service_worker too. Since that would change the Firefox manifest and trigger extra store review, we need to keep the two separate manifests for now.

The only real difference between them is background.scripts vs background.service_worker (plus browser_specific_settings.gecko), so the duplication is minimal.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect adding a service worker will trigger automated review (as opposed to a manual review for the <all_urls> change). Let's go ahead and use the service_worker method for both instead if that works.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — switched Firefox manifest to service_worker as well. Both manifests now use the same background method.

Comment on lines +5 to +6
# Shared JavaScript files used by both Firefox and Chromium builds
shared_js = ['background.js', 'content-bridge.js', 'content-main.js']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Shared JavaScript files used by both Firefox and Chromium builds
shared_js = ['background.js', 'content-bridge.js', 'content-main.js']
# Shared files used by both Firefox and Chromium builds
shared_addon_files = ['icons' / 'logo.svg', 'background.js', 'content-bridge.js', 'content-main.js']

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied all the meson.build suggestions — shared file list renamed, Firefox target updated to use the cp/mv pattern, and Chromium target added. Thanks for the detailed suggestions.

shared_js = ['background.js', 'content-bridge.js', 'content-main.js']

# Firefox XPI
firefox_files = ['manifest.firefox.json'] + shared_js + ['icons' / 'logo.svg']
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
firefox_files = ['manifest.firefox.json'] + shared_js + ['icons' / 'logo.svg']
firefox_addon_files = ['manifest.firefox.json'] + shared_addon_files

'xpi',
output: 'credentialsd-firefox-helper.xpi',
input: xpi_files,
input: firefox_files,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
input: firefox_files,
input: firefox_addon_files,

) No newline at end of file
)

# TODO: Add Chromium build target using manifest.chromium.json No newline at end of file
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a zip with the same structure as a CRX archive. You can't load it directly because it's not signed and is missing the CRX header, but if you unzip it, you can load the manifest.json directly.

The same commands should be used above for the Firefox target, except with firefox_addon_files, but Github won't let me write a suggestion for that

Suggested change
# TODO: Add Chromium build target using manifest.chromium.json
# Chromium extension (unsigned)
# TODO: Wrap this in a self-signed CRX in CI
chromium_addon_files = ['manifest.chromium.json'] + shared_addon_files
custom_target(
'chromium-extension',
output: 'credentialsd-chromium-helper.zip',
input: chromium_addon_files,
command: [
'cp', '-r', '@INPUT@', '@PRIVATE_DIR@',
'&&',
'cd',
'@PRIVATE_DIR@',
'&&',
'mv', '@PLAINNAME0@', 'manifest.json',
'&&',
'mkdir', '-p', 'icons',
'&&',
'mv', '@PLAINNAME1@', 'icons/',
'&&',
zip,
'-r',
'-FS', meson.project_build_root() / '@OUTPUT@',
'.',
],
install: true,
install_dir: addon_dir,
)

phelix001 and others added 2 commits March 11, 2026 19:40
- Revert Firefox content_scripts matches from <all_urls> back to
  restricted URL list to avoid triggering extra Firefox store review
- Apply reviewer's meson.build suggestions: rename shared file list,
  update Firefox target to use cp/mv pattern, add full Chromium
  build target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Switch Firefox from background.scripts to background.service_worker
to match the Chromium manifest, per reviewer feedback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants