feat: FireFox browser extension build

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-18 00:23:20 +02:00
1 parent 063beb317e
commit 523916d2b1
7 files changed
+248 -67

No files matched your search

+66 -7
View File
@@ -7,7 +7,7 @@ name: Extension release
# ran most recently, breaking the OTHER track's /releases/latest/download/<asset> links. Instead
# this always publishes to the same fixed tag, "extension-latest" -- deleting and recreating it
# each run -- and the Downloads page links to the tag-scoped path
# (.../releases/download/extension-latest/thingport-grab.zip), never the /latest/ alias.
# (.../releases/download/extension-latest/<asset>), never the /latest/ alias.
on:
push:
branches: [main]
@@ -26,7 +26,7 @@ concurrency:
cancel-in-progress: true
jobs:
build:
build-chrome:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -41,9 +41,56 @@ jobs:
path: dist/thingport-grab.zip
if-no-files-found: error
build-firefox:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Lint (validates the manifest for both PRs and pushes, no AMO credentials needed)
run: npx --yes web-ext@8 lint --source-dir extension
# Signing only happens on a push to main (it needs the AMO_JWT_* secrets, which forked PRs
# don't get, and there's nothing useful to attach to a PR run anyway).
#
# AMO rejects re-uploading a version number it has already seen for this add-on ID, even on
# the unlisted channel -- and extension/manifest.json's own version isn't bumped on every
# commit. So this signs a copy of the manifest with the run number appended as an extra
# version segment (e.g. 1.0.1.456), guaranteeing a unique version on every run without
# touching the checked-in version Chrome's build (or the repo) actually uses.
- name: Patch a build-local version for signing
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
cp -r extension firefox-src
base_version=$(jq -r .version firefox-src/manifest.json)
jq --arg v "${base_version}.${{ github.run_number }}" '.version = $v' \
extension/manifest.json > firefox-src/manifest.json.tmp
mv firefox-src/manifest.json.tmp firefox-src/manifest.json
- name: Sign (unlisted -- not published to addons.mozilla.org's public listing)
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
env:
WEB_EXT_API_KEY: ${{ secrets.AMO_JWT_ISSUER }}
WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }}
run: |
npx --yes web-ext@8 sign \
--source-dir firefox-src \
--artifacts-dir dist \
--channel unlisted
- name: Rename to a stable filename
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
xpi=$(ls dist/*.xpi | head -n1)
mv "$xpi" dist/thingport-grab.xpi
- uses: actions/upload-artifact@v4
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
with:
name: thingport-grab-firefox
path: dist/thingport-grab.xpi
if-no-files-found: error
release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [build]
needs: [build-chrome, build-firefox]
runs-on: ubuntu-latest
permissions:
contents: write
@@ -53,6 +100,10 @@ jobs:
with:
name: thingport-grab
path: dist
- uses: actions/download-artifact@v4
with:
name: thingport-grab-firefox
path: dist
- name: Move the extension-latest tag to this commit
env:
GH_TOKEN: ${{ github.token }}
@@ -66,7 +117,15 @@ jobs:
Automated build of the Thingport Grab browser extension (see `extension/README.md`)
from ${{ github.sha }}.
Not published to the Chrome Web Store -- unzip `thingport-grab.zip`, then in Chrome:
`chrome://extensions` → enable **Developer mode** → **Load unpacked** → select the
unzipped folder.
files: dist/thingport-grab.zip
Not published to either browser's extension store -- see `extension/README.md` for
install instructions:
- **Chrome / Edge / other Chromium browsers**: unzip `thingport-grab.zip`, then
`chrome://extensions` → enable **Developer mode** → **Load unpacked** → select the
unzipped folder.
- **Firefox**: download `thingport-grab.xpi` and open it directly (or drag it onto a
Firefox window). It's signed by Mozilla for self-distribution, so it installs and
updates like any other extension -- no Developer mode needed.
files: |
dist/thingport-grab.zip
dist/thingport-grab.xpi
+69 -6
View File
@@ -9,11 +9,15 @@ same way Thingport's own "+ Add > Import" does.
It talks directly to your Thingport instance's API from the extension's background service worker
-- no separate server, no data sent anywhere else.
## Install (no Chrome Web Store listing)
## Install (no extension store listing)
This isn't published to the Chrome Web Store, so it installs the same way the
This isn't published to either browser's extension store, so it installs the same way the
[Thingport releases](https://github.com/TautvydasDerzinskas/Thingport/releases) page's other
downloads do -- as a developer-mode "unpacked" extension:
downloads do.
### Chrome / Edge / other Chromium browsers
As a developer-mode "unpacked" extension:
1. Download `thingport-grab.zip` from the in-app Download page (or the
[`extension-latest` release](https://github.com/TautvydasDerzinskas/Thingport/releases/tag/extension-latest))
@@ -24,6 +28,23 @@ downloads do -- as a developer-mode "unpacked" extension:
4. Click the new Thingport icon in your toolbar, enter your instance's URL and your Thingport
login, and save.
### Firefox
Firefox refuses to install *any* unsigned extension outside of Developer Edition/Nightly, even for
local/unpacked use -- so unlike Chrome, this needs an actual Mozilla-signed build, not just a zip.
CI signs one on every push to `main` (see [Build / package](#build--package) below):
1. Download `thingport-grab.xpi` from the in-app Download page (or the
[`extension-latest` release](https://github.com/TautvydasDerzinskas/Thingport/releases/tag/extension-latest)).
2. Open it directly (double-click, or `File > Open File` in Firefox) -- or drag it onto a Firefox
window -- and confirm the install prompt.
3. Click the new Thingport icon in your toolbar, enter your instance's URL and your Thingport
login, and save.
It's signed for **self-distribution** (the "unlisted" channel), not listed on
addons.mozilla.org -- signing is still required for Firefox to allow the install at all, and it's
what lets Firefox treat later versions as updates to the same install instead of a fresh add-on.
## Setup
The popup asks for your instance URL (e.g. `https://thingport.example.com`) and your Thingport
@@ -67,15 +88,57 @@ just stops showing progress, it doesn't cancel anything.
## Build / package
There's no build step -- `extension/` is loaded directly by Chrome. To produce the distributable
zip by hand (CI does this on every push to `main` that touches this folder -- see
`.github/workflows/extension-release.yml`):
There's no build step -- `extension/` is loaded directly by Chrome and Firefox alike. CI packages
both browsers' distributables on every push to `main` that touches this folder (see
`.github/workflows/extension-release.yml`) and attaches them to the `extension-latest` release.
To produce the Chrome zip by hand:
```bash
cd extension
zip -r ../thingport-grab.zip . -x '*.DS_Store'
```
To produce a signed Firefox build by hand, you need a Mozilla Add-on Developer account's API
credentials (see below):
```bash
npx web-ext sign --source-dir extension --channel unlisted \
--api-key "$AMO_JWT_ISSUER" --api-secret "$AMO_JWT_SECRET"
```
Signing rejects re-uploading a version number it's already seen for this add-on ID (even on the
unlisted channel), so CI signs a copy of the manifest with the GitHub Actions run number appended
to the version (e.g. `1.0.1.456`) rather than requiring a manifest version bump on every commit --
see the workflow for details. Doing this by hand, bump `version` in `manifest.json` first instead.
### One-time setup: AMO signing credentials
Firefox requires every extension -- even self-distributed, unlisted ones -- to be signed by
Mozilla before it will install. The workflow needs two repo secrets to do this automatically:
1. Create a free account at [addons.mozilla.org](https://addons.mozilla.org) if you don't have one.
2. Go to [Manage API Keys](https://addons.mozilla.org/en-US/developers/addon/api/key/) and generate
a new API key/secret pair.
3. In the GitHub repo, add them as **Settings > Secrets and variables > Actions** secrets named
`AMO_JWT_ISSUER` (the API key) and `AMO_JWT_SECRET` (the API secret).
No manual submission through the AMO web UI is needed first -- `web-ext sign --channel unlisted`
creates the add-on listing (hidden, unlisted) on its first run.
The extension's Firefox identity (`browser_specific_settings.gecko.id` in `manifest.json`, currently
`grab@thingport.app`) is what ties every signed version together as updates to the same add-on --
changing it later creates an unrelated add-on from Mozilla's point of view, so avoid changing it
once builds have been signed and distributed.
`browser_specific_settings.gecko.data_collection_permissions` is declared as `["none"]` -- Mozilla
requires every add-on to disclose this (as of policy effective 2025-11-03) and rejects signing
without it. This only covers data sent *off-device to the extension's developer or a third party
it controls* -- the credentials/cookies this extension sends to your own self-hosted Thingport
instance don't count, since that's a destination you configure and control, not the developer. If
that ever changes (e.g. adding telemetry to a Thingport-operated service), update this declaration
to match.
## Regenerating the icons
The toolbar icon PNGs (`icons/thingport-icon-{color,dark}-{16,32,48,128}.png`) and the inline
+11 -1
View File
@@ -19,7 +19,17 @@
}
},
"background": {
"service_worker": "background.js"
"service_worker": "background.js",
"scripts": ["background.js"]
},
"browser_specific_settings": {
"gecko": {
"id": "grab@thingport.app",
"strict_min_version": "109.0",
"data_collection_permissions": {
"required": ["none"]
}
}
},
"permissions": ["storage", "tabs", "cookies", "downloads"],
"optional_host_permissions": ["*://*/*"],
+13 -2
View File
@@ -1,4 +1,4 @@
// Thingport Grab is a Chrome extension (source in /extension, built by
// Thingport Grab is a browser extension (source in /extension, built by
// .github/workflows/extension-release.yml) that imports MakerWorld/Thingiverse/Printables models
// straight from their own pages -- see extension/README.md. Unlike Bridge's own release (which
// GitHub's "latest release" points at), this one publishes to a fixed tag, "extension-latest",
@@ -8,4 +8,15 @@
// build on every release rather than ever making a new one.
const EXTENSION_RELEASE_BASE = "https://github.com/TautvydasDerzinskas/Thingport/releases/download/extension-latest";
export const EXTENSION_DOWNLOAD_URL = `${EXTENSION_RELEASE_BASE}/thingport-grab.zip`;
export type ExtensionDownload = { browser: "chrome" | "firefox"; label: string; asset: string };
// Firefox's asset is a Mozilla-signed .xpi rather than a zip -- see extension/README.md's
// "Firefox" install section for why an unpacked zip won't do there.
export const EXTENSION_DOWNLOADS: ExtensionDownload[] = [
{ browser: "chrome", label: "Chrome", asset: "thingport-grab.zip" },
{ browser: "firefox", label: "Firefox", asset: "thingport-grab.xpi" },
];
export function extensionDownloadUrl(asset: string): string {
return `${EXTENSION_RELEASE_BASE}/${asset}`;
}
+15 -7
View File
@@ -408,15 +408,23 @@
"intro": "Companion tools that work alongside your Thingport instance.",
"extension": {
"heading": "Thingport Grab",
"intro": "A Chrome extension that imports MakerWorld, Thingiverse, and Printables models straight from their own pages.",
"intro": "A browser extension that imports MakerWorld, Thingiverse, and Printables models straight from their own pages.",
"name": "Thingport Grab",
"modal": {
"heading": "Install Thingport Grab",
"step1": "Unzip the downloaded file somewhere permanent -- Chrome loads the extension from this folder every time it starts.",
"step2": "Open chrome://extensions in a new tab.",
"step3": "Turn on Developer mode (top right).",
"step4": "Click \"Load unpacked\" and select the unzipped folder.",
"step5": "Click the new Thingport icon in your toolbar and enter your instance URL and login."
"chrome": {
"heading": "Install Thingport Grab",
"step1": "Unzip the downloaded file somewhere permanent -- Chrome loads the extension from this folder every time it starts.",
"step2": "Open chrome://extensions in a new tab.",
"step3": "Turn on Developer mode (top right).",
"step4": "Click \"Load unpacked\" and select the unzipped folder.",
"step5": "Click the new Thingport icon in your toolbar and enter your instance URL and login."
},
"firefox": {
"heading": "Install Thingport Grab",
"step1": "Open the downloaded .xpi file directly, or drag it onto a Firefox window.",
"step2": "Click \"Add\" on Firefox's install confirmation prompt.",
"step3": "Click the new Thingport icon in your toolbar and enter your instance URL and login."
}
}
},
"bridge": {
+15 -7
View File
@@ -408,15 +408,23 @@
"intro": "Papildomi įrankiai, veikiantys kartu su jūsų Thingport aplinka.",
"extension": {
"heading": "Thingport Grab",
"intro": "Chrome plėtinys, importuojantis MakerWorld, Thingiverse ir Printables modelius tiesiai iš jų puslapių.",
"intro": "Naršyklės plėtinys, importuojantis MakerWorld, Thingiverse ir Printables modelius tiesiai iš jų puslapių.",
"name": "Thingport Grab",
"modal": {
"heading": "Įdiegti Thingport Grab",
"step1": "Išarchyvuokite atsisiųstą failą kur nors nuolatinėje vietoje -- Chrome įkelia plėtinį iš šio aplanko kaskart paleidus naršyklę.",
"step2": "Naujame skirtuke atidarykite chrome://extensions.",
"step3": "Įjunkite Developer mode (viršuje dešinėje).",
"step4": "Spustelėkite „Load unpacked“ ir pasirinkite išarchyvuotą aplanką.",
"step5": "Spustelėkite naują Thingport piktogramą įrankių juostoje ir įveskite savo aplinkos URL bei prisijungimo duomenis."
"chrome": {
"heading": "Įdiegti Thingport Grab",
"step1": "Išarchyvuokite atsisiųstą failą kur nors nuolatinėje vietoje -- Chrome įkelia plėtinį iš šio aplanko kaskart paleidus naršyklę.",
"step2": "Naujame skirtuke atidarykite chrome://extensions.",
"step3": "Įjunkite Developer mode (viršuje dešinėje).",
"step4": "Spustelėkite „Load unpacked“ ir pasirinkite išarchyvuotą aplanką.",
"step5": "Spustelėkite naują Thingport piktogramą įrankių juostoje ir įveskite savo aplinkos URL bei prisijungimo duomenis."
},
"firefox": {
"heading": "Įdiegti Thingport Grab",
"step1": "Atidarykite atsisiųstą .xpi failą tiesiogiai arba nutempkite jį į Firefox langą.",
"step2": "Firefox diegimo lange spustelėkite „Add“.",
"step3": "Spustelėkite naują Thingport piktogramą įrankių juostoje ir įveskite savo aplinkos URL bei prisijungimo duomenis."
}
}
},
"bridge": {
+59 -37
View File
@@ -17,7 +17,7 @@ import TerminalIcon from "@mui/icons-material/Terminal";
import CableIcon from "@mui/icons-material/Cable";
import { usePageHeader } from "../../components/Layout/PageHeaderContext";
import { BRIDGE_DOWNLOADS, bridgeDownloadUrl, type BridgeDownload } from "../../constants/bridge";
import { EXTENSION_DOWNLOAD_URL } from "../../constants/extension";
import { EXTENSION_DOWNLOADS, extensionDownloadUrl, type ExtensionDownload } from "../../constants/extension";
import extensionIcon from "../../assets/logos/thingport-icon-color.svg";
const OS_ICON = { windows: LaptopWindowsIcon, macos: AppleIcon, linux: TerminalIcon };
@@ -81,7 +81,22 @@ export default function DownloadPage() {
usePageHeader({ title: t("sidebar.downloads") });
const [installOs, setInstallOs] = useState<BridgeDownload["os"] | null>(null);
const [extensionInstallOpen, setExtensionInstallOpen] = useState(false);
const [extensionInstallBrowser, setExtensionInstallBrowser] = useState<ExtensionDownload["browser"] | null>(null);
const extensionInstallSteps: Record<ExtensionDownload["browser"], InstallStep[]> = {
chrome: [
{ text: t("download.extension.modal.chrome.step1") },
{ text: t("download.extension.modal.chrome.step2") },
{ text: t("download.extension.modal.chrome.step3") },
{ text: t("download.extension.modal.chrome.step4") },
{ text: t("download.extension.modal.chrome.step5") },
],
firefox: [
{ text: t("download.extension.modal.firefox.step1") },
{ text: t("download.extension.modal.firefox.step2") },
{ text: t("download.extension.modal.firefox.step3") },
],
};
const installSteps: Record<BridgeDownload["os"], InstallStep[]> = {
windows: [{ text: t("download.modal.windows.step1") }],
@@ -116,22 +131,28 @@ export default function DownloadPage() {
<Typography variant="body2" color="text.secondary">{t("download.extension.intro")}</Typography>
</Box>
</Stack>
<Paper variant="outlined" sx={{ p: 2.5, maxWidth: 340 }}>
<Stack spacing={1.5} alignItems="flex-start">
<Box component="img" src={extensionIcon} alt="" sx={{ width: 40, height: 40 }} />
<Typography variant="subtitle2" fontWeight={600}>{t("download.extension.name")}</Typography>
<Button
component="a"
href={EXTENSION_DOWNLOAD_URL}
variant="outlined"
size="small"
startIcon={<DownloadIcon fontSize="small" />}
onClick={() => setExtensionInstallOpen(true)}
>
{t("common:download")}
</Button>
</Stack>
</Paper>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
{EXTENSION_DOWNLOADS.map(({ browser, label, asset }) => (
<Paper key={browser} variant="outlined" sx={{ p: 2.5, flex: 1, maxWidth: 340 }}>
<Stack spacing={1.5} alignItems="flex-start">
<Box component="img" src={extensionIcon} alt="" sx={{ width: 40, height: 40 }} />
<Typography variant="subtitle2" fontWeight={600}>
{t("download.extension.name")} ({label})
</Typography>
<Button
component="a"
href={extensionDownloadUrl(asset)}
variant="outlined"
size="small"
startIcon={<DownloadIcon fontSize="small" />}
onClick={() => setExtensionInstallBrowser(browser)}
>
{t("common:download")}
</Button>
</Stack>
</Paper>
))}
</Stack>
</Stack>
<Divider />
@@ -191,25 +212,26 @@ export default function DownloadPage() {
)}
</Dialog>
<Dialog open={extensionInstallOpen} onClose={() => setExtensionInstallOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>{t("download.extension.modal.heading")}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
{t("download.modal.startedNote")}
</Typography>
<InstallSteps
steps={[
{ text: t("download.extension.modal.step1") },
{ text: t("download.extension.modal.step2") },
{ text: t("download.extension.modal.step3") },
{ text: t("download.extension.modal.step4") },
{ text: t("download.extension.modal.step5") },
]}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setExtensionInstallOpen(false)}>{t("common:close")}</Button>
</DialogActions>
<Dialog
open={extensionInstallBrowser !== null}
onClose={() => setExtensionInstallBrowser(null)}
maxWidth="xs"
fullWidth
>
{extensionInstallBrowser && (
<>
<DialogTitle>{t(`download.extension.modal.${extensionInstallBrowser}.heading`)}</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
{t("download.modal.startedNote")}
</Typography>
<InstallSteps steps={extensionInstallSteps[extensionInstallBrowser]} />
</DialogContent>
<DialogActions>
<Button onClick={() => setExtensionInstallBrowser(null)}>{t("common:close")}</Button>
</DialogActions>
</>
)}
</Dialog>
</Stack>
);