diff --git a/.gitignore b/.gitignore
index 79faf8f..2ce9e32 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,16 @@
-extension.zip
\ No newline at end of file
+# OS
+.DS_Store
+
+# Build outputs
+/dist/
+*.zip
+
+# Local scripts cache or temp
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Editor
+.idea/
+.vscode/
diff --git a/README.md b/README.md
index 6b84b00..780342d 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# TranscripTonic
-Simple Google Meet transcripts. Private and open source.
+Simple Google Meet transcripts. Private and open source. Works on Chrome and Firefox.

@@ -21,10 +21,17 @@ View video on [YouTube](https://www.youtube.com/watch?v=ARL6HbkakX4)
# Installation
+
+## Chrome
+## Firefox
+Firefox users can install the extension using the unpacked installation method described below, or wait for the official Firefox Add-ons store release.
+
+**Note:** Firefox requires Manifest V2 format, so a separate `manifest-firefox.json` file is provided for Firefox compatibility.
+
@@ -69,7 +76,7 @@ When this happens, it might be possible to recover the transcript, but recovery
# Privacy policy
-TranscripTonic Chrome extension does not collect any information from users in any manner, except anonymous errors and transcript download timestamp. All processing/transcript storage happens within the user's Chrome browser and does not leave the device, unless you configure a webhook and choose to post data to your webhook URL.
+TranscripTonic browser extension does not collect any information from users in any manner, except anonymous errors and transcript download timestamp. All processing/transcript storage happens within the user's browser and does not leave the device, unless you configure a webhook and choose to post data to your webhook URL.
@@ -79,3 +86,26 @@ The transcript may not always be accurate and is only intended to aid in improvi
+
+# Installing unpacked extension
+This method works for both Chrome and Firefox browsers.
+
+1. Download the unpacked extension zip file from GitHub using this [link](https://raw.githubusercontent.com/vivek-nexus/transcriptonic/refs/heads/main/extension-unpacked.zip)
+
+## For Chrome:
+2. Open `chrome://extensions` in a new Chrome tab
+3. Enable "Developer mode" from top right corner
+4. Drag and drop the unpacked extension zip file to complete the installation process
+5. If drag and drop of zip file does not work, unzip the file. Click on "Load unpacked" in chrome extensions page and select the `extension-unpacked` folder to complete the installation process.
+
+## For Firefox:
+2. Open `about:debugging` in a new Firefox tab
+3. Click "This Firefox" in the left sidebar
+4. Click "Load Temporary Add-on"
+5. Unzip the downloaded file and navigate to the `extension-unpacked` folder
+6. **Important:** Select specifically the `manifest-firefox.json` file (NOT `manifest.json`)
+
+**Note:** Remove unpacked extension when no longer needed. Your meeting data of unpacked extension and extension installed from official stores are stored separately.
+
+
+
diff --git a/extension-unpacked.zip b/extension-unpacked.zip
deleted file mode 100644
index 211ce1b..0000000
Binary files a/extension-unpacked.zip and /dev/null differ
diff --git a/extension/background.js b/extension/background.js
index 4aa74ab..326eeec 100644
--- a/extension/background.js
+++ b/extension/background.js
@@ -352,55 +352,61 @@ function downloadTranscript(index, isWebhookEnabled) {
content += "Transcript saved using TranscripTonic Chrome extension (https://chromewebstore.google.com/detail/ciepnfnceimjehngolkijpnbappkkiag)"
content += "\n---------------"
- const blob = new Blob([content], { type: "text/plain" })
+ if (isFirefox()) {
+ // Firefox: use message passing to meetings.html for download
+ sendDownloadToMeetingsPage(fileName, content, resolve, reject);
+ } else {
+ // Chrome: use downloads API
+ const blob = new Blob([content], { type: "text/plain" })
- // Read the blob as a data URL
- const reader = new FileReader()
+ // Read the blob as a data URL
+ const reader = new FileReader()
- // Read the blob
- reader.readAsDataURL(blob)
+ // Read the blob
+ reader.readAsDataURL(blob)
- // Download as text file, once blob is read
- reader.onload = function (event) {
- if (event.target?.result) {
- const dataUrl = event.target.result
+ // Download as text file, once blob is read
+ reader.onload = function (event) {
+ if (event.target?.result) {
+ const dataUrl = event.target.result
- // Create a download with Chrome Download API
- chrome.downloads.download({
- // @ts-ignore
- url: dataUrl,
- filename: fileName,
- conflictAction: "uniquify"
- }).then(() => {
- console.log("Transcript downloaded")
- resolve("Transcript downloaded successfully")
-
- // Increment anonymous transcript generated count to a Google sheet
- fetch(`https://script.google.com/macros/s/AKfycbxgUPDKDfreh2JIs8pIC-9AyQJxq1lx9Q1qI2SVBjJRvXQrYCPD2jjnBVQmds2mYeD5nA/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}&meetingSoftware=${meeting.meetingSoftware}`, {
- mode: "no-cors"
- })
- }).catch((err) => {
- console.error(err)
+ // Create a download with Chrome Download API
chrome.downloads.download({
// @ts-ignore
url: dataUrl,
- filename: "TranscripTonic/Transcript.txt",
+ filename: fileName,
conflictAction: "uniquify"
- })
- console.log("Invalid file name. Transcript downloaded to TranscripTonic directory with simple file name.")
- resolve("Transcript downloaded successfully with default file name")
+ }).then(() => {
+ console.log("Transcript downloaded")
+ resolve("Transcript downloaded successfully")
+
+ // Increment anonymous transcript generated count to a Google sheet
+ fetch(`https://script.google.com/macros/s/AKfycbxgUPDKDfreh2JIs8pIC-9AyQJxq1lx9Q1qI2SVBjJRvXQrYCPD2jjnBVQmds2mYeD5nA/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}&meetingSoftware=${meeting.meetingSoftware}`, {
+ mode: "no-cors"
+ })
+ }).catch((err) => {
+ console.error(err)
+ chrome.downloads.download({
+ // @ts-ignore
+ url: dataUrl,
+ filename: "TranscripTonic/Transcript.txt",
+ conflictAction: "uniquify"
+ })
+ console.log("Invalid file name. Transcript downloaded to TranscripTonic directory with simple file name.")
+ resolve("Transcript downloaded successfully with default file name")
- // Logs anonymous errors to a Google sheet for swift debugging
- fetch(`https://script.google.com/macros/s/AKfycbwN-bVkVv3YX4qvrEVwG9oSup0eEd3R22kgKahsQ3bCTzlXfRuaiO7sUVzH9ONfhL4wbA/exec?version=${chrome.runtime.getManifest().version}&code=009&error=${encodeURIComponent(err)}&meetingSoftware=${meeting.meetingSoftware}`, { mode: "no-cors" })
+ // Logs anonymous errors to a Google sheet for swift debugging
+ fetch(`https://script.google.com/macros/s/AKfycbwN-bVkVv3YX4qvrEVwG9oSup0eEd3R22kgKahsQ3bCTzlXfRuaiO7sUVzH9ONfhL4wbA/exec?version=${chrome.runtime.getManifest().version}&code=009&error=${encodeURIComponent(err)}&meetingSoftware=${meeting.meetingSoftware}`, { mode: "no-cors" })
- // Increment anonymous transcript generated count to a Google sheet
- fetch(`https://script.google.com/macros/s/AKfycbxgUPDKDfreh2JIs8pIC-9AyQJxq1lx9Q1qI2SVBjJRvXQrYCPD2jjnBVQmds2mYeD5nA/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}&meetingSoftware=${meeting.meetingSoftware}`, {
- mode: "no-cors"
+ // Increment anonymous transcript generated count to a Google sheet
+ fetch(`https://script.google.com/macros/s/AKfycbxgUPDKDfreh2JIs8pIC-9AyQJxq1lx9Q1qI2SVBjJRvXQrYCPD2jjnBVQmds2mYeD5nA/exec?version=${chrome.runtime.getManifest().version}&isWebhookEnabled=${isWebhookEnabled}&meetingSoftware=${meeting.meetingSoftware}`, {
+ mode: "no-cors"
+ })
})
- })
- }
- else {
- reject({ errorCode: "009", errorMessage: "Failed to read blob" })
+ }
+ else {
+ reject({ errorCode: "009", errorMessage: "Failed to read blob" })
+ }
}
}
}
@@ -669,4 +675,72 @@ function registerContentScripts(showNotification = true) {
})
})
})
+}
+
+function isFirefox() {
+ // @ts-ignore - browser is a Firefox-specific global
+ return typeof browser !== 'undefined' && /firefox/i.test(navigator.userAgent);
+}
+
+/**
+ * Firefox-compatible download handler that sends blob to meetings.html
+ * @param {string} fileName
+ * @param {string} content
+ * @param {Function} resolve
+ * @param {Function} reject
+ */
+function sendDownloadToMeetingsPage(fileName, content, resolve, reject) {
+ // Find or open meetings.html, then send the download message
+ chrome.tabs.query({}, function (tabs) {
+ let meetingsTab = tabs.find(tab => tab.url && tab.url.includes('meetings.html'));
+ if (meetingsTab && meetingsTab.id) {
+ chrome.tabs.update(meetingsTab.id, { active: true }, function () {
+ if (meetingsTab.id) {
+ chrome.tabs.sendMessage(
+ meetingsTab.id,
+ {
+ type: "download_transcript_blob",
+ fileName: fileName,
+ blobContent: content
+ },
+ function (response) {
+ if (response && response.success) {
+ resolve("Transcript downloaded successfully (Firefox)");
+ } else {
+ reject(new Error("Failed to trigger download in Firefox (meetings.html)"));
+ }
+ }
+ );
+ }
+ });
+ } else {
+ // Open meetings.html in a new tab
+ chrome.tabs.create({ url: chrome.runtime.getURL('meetings.html'), active: true }, function (newTab) {
+ // Wait for the tab to load, then send the message
+ const listener = function (tabId, changeInfo) {
+ if (tabId === newTab.id && changeInfo.status === 'complete') {
+ chrome.tabs.onUpdated.removeListener(listener);
+ if (newTab.id) {
+ chrome.tabs.sendMessage(
+ newTab.id,
+ {
+ type: "download_transcript_blob",
+ fileName: fileName,
+ blobContent: content
+ },
+ function (response) {
+ if (response && response.success) {
+ resolve("Transcript downloaded successfully (Firefox)");
+ } else {
+ reject(new Error("Failed to trigger download in Firefox (new meetings.html)"));
+ }
+ }
+ );
+ }
+ }
+ };
+ chrome.tabs.onUpdated.addListener(listener);
+ });
+ }
+ });
}
\ No newline at end of file
diff --git a/extension/manifest-firefox.json b/extension/manifest-firefox.json
new file mode 100644
index 0000000..14582cb
--- /dev/null
+++ b/extension/manifest-firefox.json
@@ -0,0 +1,40 @@
+{
+ "manifest_version": 2,
+ "name": "TranscripTonic",
+ "version": "3.2.5",
+ "description": "Simple Google Meet transcripts. Private and open source.",
+ "browser_action": {
+ "default_icon": "icon.png",
+ "default_popup": "popup.html"
+ },
+ "icons": {
+ "128": "icon.png"
+ },
+ "content_scripts": [
+ {
+ "js": [
+ "content.js"
+ ],
+ "run_at": "document_end",
+ "matches": [
+ "https://meet.google.com/*"
+ ],
+ "exclude_matches": [
+ "https://meet.google.com/"
+ ]
+ }
+ ],
+ "permissions": [
+ "storage",
+ "https://meet.google.com/*",
+ "https://*/*"
+ ],
+ "background": {
+ "scripts": [
+ "background.js"
+ ]
+ },
+ "web_accessible_resources": [
+ "meetings.html"
+ ]
+}
diff --git a/extension/manifest.json b/extension/manifest.json
index 173a3cc..37f5785 100644
--- a/extension/manifest.json
+++ b/extension/manifest.json
@@ -1,4 +1,5 @@
{
+ "_comment": "This is the Chrome/Manifest V3 version. Firefox users should use manifest-firefox.json (Manifest V2)",
"manifest_version": 3,
"name": "TranscripTonic",
"version": "3.2.5",
@@ -43,5 +44,11 @@
],
"background": {
"service_worker": "background.js"
- }
+ },
+ "web_accessible_resources": [
+ {
+ "resources": ["icon.png", "popup.html", "meetings.html"],
+ "matches": ["https://meet.google.com/*"]
+ }
+ ]
}
\ No newline at end of file
diff --git a/extension/meetings.js b/extension/meetings.js
index 37b3a82..52f75f5 100644
--- a/extension/meetings.js
+++ b/extension/meetings.js
@@ -334,4 +334,31 @@ function getDuration(meetingStartTimestamp, meetingEndTimestamp) {
return durationHours > 0
? `${durationHours}h ${remainingMinutes}m`
: `${durationMinutes}m`
+}
+
+// Add Firefox download support
+if (typeof browser !== 'undefined' && /firefox/i.test(navigator.userAgent)) {
+ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
+ if (message.type === 'download_transcript_blob') {
+ try {
+ const blob = new Blob([message.blobContent], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = message.fileName;
+ document.body.appendChild(a);
+ a.click();
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, 100);
+ if (sendResponse) sendResponse({ success: true });
+ } catch (e) {
+ if (sendResponse) sendResponse({ success: false });
+ }
+ return true;
+ }
+ });
+ // If meetings.html is opened by the background script, focus the window
+ window.focus();
}
\ No newline at end of file
diff --git a/scripts/build-cross.sh b/scripts/build-cross.sh
new file mode 100755
index 0000000..4b8d63a
--- /dev/null
+++ b/scripts/build-cross.sh
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# Build Chrome (MV3) and Firefox (MV2) variants from unified root using separate manifests
+
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SRC_DIR="$ROOT_DIR/extension"
+DIST_DIR="$ROOT_DIR/dist"
+CHROME_OUT="$DIST_DIR/chrome"
+FIREFOX_OUT="$DIST_DIR/amo"
+
+echo "[build] Root: $ROOT_DIR"
+
+rm -rf "$CHROME_OUT" "$FIREFOX_OUT"
+mkdir -p "$CHROME_OUT/icons" "$FIREFOX_OUT/icons" "$DIST_DIR"
+
+read_version() {
+ local manifest_path="$1"
+ if command -v python3 >/dev/null 2>&1; then
+ python3 - "$manifest_path" <<'PY'
+import json,sys
+path=sys.argv[1]
+with open(path,'r') as f:
+ data=json.load(f)
+print(data.get('version','0.0.0'))
+PY
+ else
+ grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$manifest_path" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]*)".*/\1/'
+ fi
+}
+
+VERSION=$(read_version "$SRC_DIR/manifest.json")
+echo "[build] Version: $VERSION"
+
+copy_common() {
+ local from="$1"; local to="$2"
+ local files=(background.js content.js meetings.html meetings.js popup.html popup.js icon.png)
+ for f in "${files[@]}"; do
+ if [[ -f "$from/$f" ]]; then cp "$from/$f" "$to/$f"; fi
+ done
+ if [[ -d "$from/icons" ]]; then
+ find "$from/icons" -maxdepth 1 -type f -name '*.svg' -exec cp {} "$to/icons/" \;
+ fi
+}
+
+strip_ts_refs() {
+ local f="$1"
+ if [[ -f "$f" ]]; then
+ if [[ "$OSTYPE" == darwin* ]]; then
+ sed -i '' -E 's#^/// /dev/null 2>&1; then
+ echo "[lint] Running web-ext lint (Firefox)..."
+ web-ext lint --source-dir "$FIREFOX_OUT" || echo "[lint] web-ext reported warnings/errors (continuing)."
+fi
+FIREFOX_ZIP="$DIST_DIR/transcriptonic-firefox-v$VERSION.zip"
+(cd "$FIREFOX_OUT" && zip -qr "$FIREFOX_ZIP" . -x '*.DS_Store')
+
+echo "[done] Chrome: $CHROME_ZIP"
+echo "[done] Firefox: $FIREFOX_ZIP"
+
+