ATlast — you'll never need to find your favorites on another platform again. Find your favs in the ATmosphere.
atproto

feat: add Firefox support with webextension-polyfill

Implemented cross-browser compatibility for the extension:

- Installed webextension-polyfill for unified browser.* API
- Replaced all chrome.* API calls with browser.* imports
- Updated build system to output both chrome/ and firefox/ directories
- Created Firefox-specific manifest with browser_specific_settings

byarielm.fyi be9f4207 603cf0a1

verified
+18 -10
docs/git-history.json
··· 1 1 [ 2 2 { 3 + "hash": "603cf0a187850664336a12c9e5cbb49038906f53", 4 + "short_hash": "603cf0a", 5 + "author": "Ariel M. Lighty", 6 + "date": "2025-12-27T22:42:43-05:00", 7 + "message": "fix: CORS for extension credentialed requests\n\nUpdated CORS headers to support credentials from Chrome extensions:\n- Added getCorsHeaders() to detect chrome-extension:// origins\n- Changed from wildcard Access-Control-Allow-Origin to specific origin\n- Added Access-Control-Allow-Credentials: true for credentialed requests\n- Updated session endpoint to pass event for CORS header detection", 8 + "files_changed": 4 9 + }, 10 + { 3 11 "hash": "bd3aabb75abb1875aef125610fcdccb14967a8e3", 4 12 "short_hash": "bd3aabb", 5 13 "author": "Ariel M. Lighty", ··· 8 16 "files_changed": 5 9 17 }, 10 18 { 11 - "hash": "d07180cd3a19328b82b35118e525b59d4e2e060b", 12 - "short_hash": "d07180c", 19 + "hash": "bd3aabb75abb1875aef125610fcdccb14967a8e3", 20 + "short_hash": "bd3aabb", 13 21 "author": "Ariel M. Lighty", 14 - "date": "2025-12-27T18:38:39-05:00", 15 - "message": "feat: add Tailwind CSS to extension\n\nReplaced 299 lines of vanilla CSS with Tailwind for design consistency with web app. Production build minified to 13KB.", 16 - "files_changed": 9 22 + "date": "2025-12-27T22:10:11-05:00", 23 + "message": "fix: extension dark mode and build mode messaging\n\n- Changed darkMode from 'class' to 'media' for automatic system preference detection\n- Made server offline message conditional on build mode (dev vs prod)\n- Hide dev server instructions in production builds", 24 + "files_changed": 5 17 25 }, 18 26 { 19 27 "hash": "d07180cd3a19328b82b35118e525b59d4e2e060b", ··· 32 40 "files_changed": 6 33 41 }, 34 42 { 35 - "hash": "fcf682bb8969aca108262348e7e17531077713be", 36 - "short_hash": "fcf682b", 43 + "hash": "2a163c5f033a79324b100a236ea26c905909bfc6", 44 + "short_hash": "2a163c5", 37 45 "author": "Ariel M. Lighty", 38 - "date": "2025-12-27T15:48:44-05:00", 39 - "message": "docs: improve decision graph workflow with lifecycle management\n\nUpdated CLAUDE.md with comprehensive node lifecycle management:\n- Added node status transitions (pending → in_progress → completed)\n- Correct orphan detection commands (awk instead of cut)\n- Common mistakes section with examples\n- Enhanced audit checklist with status verification\n- Verification workflow after node creation\n\nAlso updated extension popup with ATmosphere branding.\n\nDecision graph now at 331 nodes, 332 edges - all orphans resolved.", 40 - "files_changed": 4 46 + "date": "2025-12-27T15:49:08-05:00", 47 + "message": "docs: update decision graph after documentation improvements", 48 + "files_changed": 2 41 49 }, 42 50 { 43 51 "hash": "fcf682bb8969aca108262348e7e17531077713be",
+231
docs/graph-data.json
··· 4014 4014 "created_at": "2025-12-27T22:41:38.430661200-05:00", 4015 4015 "updated_at": "2025-12-27T22:41:48.981429600-05:00", 4016 4016 "metadata_json": "{\"branch\":\"master\",\"confidence\":95}" 4017 + }, 4018 + { 4019 + "id": 366, 4020 + "change_id": "77b7ed7e-a113-41f6-a677-50d376f3f008", 4021 + "node_type": "outcome", 4022 + "title": "Committed CORS fixes to git", 4023 + "description": null, 4024 + "status": "completed", 4025 + "created_at": "2025-12-27T22:42:49.037783-05:00", 4026 + "updated_at": "2025-12-27T22:42:54.162857-05:00", 4027 + "metadata_json": "{\"branch\":\"master\",\"commit\":\"603cf0a\",\"confidence\":95}" 4028 + }, 4029 + { 4030 + "id": 367, 4031 + "change_id": "df6abf7a-e7a4-45f3-8485-b933319416d9", 4032 + "node_type": "goal", 4033 + "title": "Create Firefox-compatible version of Twitter scraper extension", 4034 + "description": null, 4035 + "status": "pending", 4036 + "created_at": "2025-12-28T18:09:33.241860800-05:00", 4037 + "updated_at": "2025-12-28T18:09:33.241860800-05:00", 4038 + "metadata_json": "{\"branch\":\"master\",\"confidence\":85,\"prompt\":\"let's make the extension have a firefox compatible version too.\"}" 4039 + }, 4040 + { 4041 + "id": 368, 4042 + "change_id": "79721edf-aa05-4580-8c28-7d20941ef155", 4043 + "node_type": "observation", 4044 + "title": "Current extension uses Manifest V3 with Chrome-specific APIs", 4045 + "description": null, 4046 + "status": "pending", 4047 + "created_at": "2025-12-28T18:10:08.441348100-05:00", 4048 + "updated_at": "2025-12-28T18:10:08.441348100-05:00", 4049 + "metadata_json": "{\"branch\":\"master\",\"confidence\":95}" 4050 + }, 4051 + { 4052 + "id": 369, 4053 + "change_id": "783841d0-c096-48f6-be18-193a9dcc7d4b", 4054 + "node_type": "observation", 4055 + "title": "Firefox compatibility analysis: Extension uses chrome.* APIs (runtime.sendMessage, storage.local, tabs.query/sendMessage), MV3 service worker. Firefox supports MV3 but has differences. Options: 1) Use webextension-polyfill for cross-browser, 2) Dual manifests (MV3 Chrome + MV2 Firefox), 3) Keep MV3 for both with minimal changes. Current build outputs to dist/chrome only.", 4056 + "description": null, 4057 + "status": "pending", 4058 + "created_at": "2025-12-28T18:10:48.087066800-05:00", 4059 + "updated_at": "2025-12-28T18:10:48.087066800-05:00", 4060 + "metadata_json": "{\"branch\":\"master\",\"confidence\":90}" 4061 + }, 4062 + { 4063 + "id": 370, 4064 + "change_id": "fd2d5b63-c26c-4592-89a6-3ccb4234c3c6", 4065 + "node_type": "decision", 4066 + "title": "Choose Firefox compatibility approach: webextension-polyfill, dual manifests, or minimal MV3 changes", 4067 + "description": null, 4068 + "status": "pending", 4069 + "created_at": "2025-12-28T18:10:50.375270400-05:00", 4070 + "updated_at": "2025-12-28T18:10:50.375270400-05:00", 4071 + "metadata_json": "{\"branch\":\"master\",\"confidence\":80}" 4072 + }, 4073 + { 4074 + "id": 371, 4075 + "change_id": "159906da-984f-4a1d-a1a6-98e0fc0cf369", 4076 + "node_type": "option", 4077 + "title": "Use webextension-polyfill library for unified cross-browser API", 4078 + "description": null, 4079 + "status": "pending", 4080 + "created_at": "2025-12-28T18:11:05.947924200-05:00", 4081 + "updated_at": "2025-12-28T18:11:05.947924200-05:00", 4082 + "metadata_json": "{\"branch\":\"master\",\"confidence\":85}" 4083 + }, 4084 + { 4085 + "id": 372, 4086 + "change_id": "df5e42e6-53c1-4b30-8b6f-f2385cd9e247", 4087 + "node_type": "option", 4088 + "title": "Dual manifests: MV3 for Chrome, MV2 for Firefox with separate builds", 4089 + "description": null, 4090 + "status": "pending", 4091 + "created_at": "2025-12-28T18:11:08.179938100-05:00", 4092 + "updated_at": "2025-12-28T18:11:08.179938100-05:00", 4093 + "metadata_json": "{\"branch\":\"master\",\"confidence\":85}" 4094 + }, 4095 + { 4096 + "id": 373, 4097 + "change_id": "7bb58202-7a9b-4e8b-8b9e-927e5106bce7", 4098 + "node_type": "option", 4099 + "title": "Keep MV3 for both browsers with minimal manifest tweaks", 4100 + "description": null, 4101 + "status": "pending", 4102 + "created_at": "2025-12-28T18:11:10.370113600-05:00", 4103 + "updated_at": "2025-12-28T18:11:10.370113600-05:00", 4104 + "metadata_json": "{\"branch\":\"master\",\"confidence\":85}" 4105 + }, 4106 + { 4107 + "id": 374, 4108 + "change_id": "d41b29e0-cd48-4dac-a6c8-c6179612702e", 4109 + "node_type": "outcome", 4110 + "title": "Chose webextension-polyfill approach. Provides unified browser.* API, Promise-based, future-proof MV3 for both browsers, +20KB but cleaner codebase", 4111 + "description": null, 4112 + "status": "pending", 4113 + "created_at": "2025-12-28T19:04:24.676770900-05:00", 4114 + "updated_at": "2025-12-28T19:04:24.676770900-05:00", 4115 + "metadata_json": "{\"branch\":\"master\",\"confidence\":95}" 4116 + }, 4117 + { 4118 + "id": 375, 4119 + "change_id": "5bb34b8b-aec4-4f84-993e-eb9bf7a2d13f", 4120 + "node_type": "action", 4121 + "title": "Installing webextension-polyfill and updating source files to use browser.* API", 4122 + "description": null, 4123 + "status": "pending", 4124 + "created_at": "2025-12-28T19:08:14.642882400-05:00", 4125 + "updated_at": "2025-12-28T19:08:14.642882400-05:00", 4126 + "metadata_json": "{\"branch\":\"master\",\"confidence\":90}" 4127 + }, 4128 + { 4129 + "id": 376, 4130 + "change_id": "644181ee-5a44-4967-9657-e9dd5f648c5e", 4131 + "node_type": "outcome", 4132 + "title": "Successfully implemented Firefox compatibility with webextension-polyfill. Both Chrome and Firefox builds compile successfully. Chrome uses service_worker (MV3), Firefox uses scripts array with browser_specific_settings. All chrome.* API calls replaced with browser.* imports.", 4133 + "description": null, 4134 + "status": "pending", 4135 + "created_at": "2025-12-28T19:14:22.309457600-05:00", 4136 + "updated_at": "2025-12-28T19:14:22.309457600-05:00", 4137 + "metadata_json": "{\"branch\":\"master\",\"confidence\":95}" 4017 4138 } 4018 4139 ], 4019 4140 "edges": [ ··· 8009 8130 "weight": 1.0, 8010 8131 "rationale": "CORS fix completed", 8011 8132 "created_at": "2025-12-27T22:41:44.160528300-05:00" 8133 + }, 8134 + { 8135 + "id": 364, 8136 + "from_node_id": 360, 8137 + "to_node_id": 366, 8138 + "from_change_id": "706d5a7f-08ed-43f7-aee5-0bed28d9402a", 8139 + "to_change_id": "77b7ed7e-a113-41f6-a677-50d376f3f008", 8140 + "edge_type": "leads_to", 8141 + "weight": 1.0, 8142 + "rationale": "Git commit for CORS fixes", 8143 + "created_at": "2025-12-27T22:42:51.663598100-05:00" 8144 + }, 8145 + { 8146 + "id": 365, 8147 + "from_node_id": 367, 8148 + "to_node_id": 368, 8149 + "from_change_id": "df6abf7a-e7a4-45f3-8485-b933319416d9", 8150 + "to_change_id": "79721edf-aa05-4580-8c28-7d20941ef155", 8151 + "edge_type": "leads_to", 8152 + "weight": 1.0, 8153 + "rationale": "Analysis step for Firefox compatibility", 8154 + "created_at": "2025-12-28T18:10:09.484445500-05:00" 8155 + }, 8156 + { 8157 + "id": 366, 8158 + "from_node_id": 368, 8159 + "to_node_id": 369, 8160 + "from_change_id": "79721edf-aa05-4580-8c28-7d20941ef155", 8161 + "to_change_id": "783841d0-c096-48f6-be18-193a9dcc7d4b", 8162 + "edge_type": "leads_to", 8163 + "weight": 1.0, 8164 + "rationale": "Detailed analysis of compatibility issues", 8165 + "created_at": "2025-12-28T18:10:49.163552300-05:00" 8166 + }, 8167 + { 8168 + "id": 367, 8169 + "from_node_id": 369, 8170 + "to_node_id": 370, 8171 + "from_change_id": "783841d0-c096-48f6-be18-193a9dcc7d4b", 8172 + "to_change_id": "fd2d5b63-c26c-4592-89a6-3ccb4234c3c6", 8173 + "edge_type": "leads_to", 8174 + "weight": 1.0, 8175 + "rationale": "Need to decide implementation strategy", 8176 + "created_at": "2025-12-28T18:10:51.434960600-05:00" 8177 + }, 8178 + { 8179 + "id": 368, 8180 + "from_node_id": 370, 8181 + "to_node_id": 371, 8182 + "from_change_id": "fd2d5b63-c26c-4592-89a6-3ccb4234c3c6", 8183 + "to_change_id": "159906da-984f-4a1d-a1a6-98e0fc0cf369", 8184 + "edge_type": "leads_to", 8185 + "weight": 1.0, 8186 + "rationale": "Option A", 8187 + "created_at": "2025-12-28T18:11:07.060637-05:00" 8188 + }, 8189 + { 8190 + "id": 369, 8191 + "from_node_id": 370, 8192 + "to_node_id": 372, 8193 + "from_change_id": "fd2d5b63-c26c-4592-89a6-3ccb4234c3c6", 8194 + "to_change_id": "df5e42e6-53c1-4b30-8b6f-f2385cd9e247", 8195 + "edge_type": "leads_to", 8196 + "weight": 1.0, 8197 + "rationale": "Option B", 8198 + "created_at": "2025-12-28T18:11:09.223792400-05:00" 8199 + }, 8200 + { 8201 + "id": 370, 8202 + "from_node_id": 370, 8203 + "to_node_id": 373, 8204 + "from_change_id": "fd2d5b63-c26c-4592-89a6-3ccb4234c3c6", 8205 + "to_change_id": "7bb58202-7a9b-4e8b-8b9e-927e5106bce7", 8206 + "edge_type": "leads_to", 8207 + "weight": 1.0, 8208 + "rationale": "Option C", 8209 + "created_at": "2025-12-28T18:11:11.439827800-05:00" 8210 + }, 8211 + { 8212 + "id": 371, 8213 + "from_node_id": 370, 8214 + "to_node_id": 374, 8215 + "from_change_id": "fd2d5b63-c26c-4592-89a6-3ccb4234c3c6", 8216 + "to_change_id": "d41b29e0-cd48-4dac-a6c8-c6179612702e", 8217 + "edge_type": "leads_to", 8218 + "weight": 1.0, 8219 + "rationale": "User selected option 1", 8220 + "created_at": "2025-12-28T19:04:26.708742600-05:00" 8221 + }, 8222 + { 8223 + "id": 372, 8224 + "from_node_id": 374, 8225 + "to_node_id": 375, 8226 + "from_change_id": "d41b29e0-cd48-4dac-a6c8-c6179612702e", 8227 + "to_change_id": "5bb34b8b-aec4-4f84-993e-eb9bf7a2d13f", 8228 + "edge_type": "leads_to", 8229 + "weight": 1.0, 8230 + "rationale": "Implementation based on decision", 8231 + "created_at": "2025-12-28T19:08:16.677078600-05:00" 8232 + }, 8233 + { 8234 + "id": 373, 8235 + "from_node_id": 375, 8236 + "to_node_id": 376, 8237 + "from_change_id": "5bb34b8b-aec4-4f84-993e-eb9bf7a2d13f", 8238 + "to_change_id": "644181ee-5a44-4967-9657-e9dd5f648c5e", 8239 + "edge_type": "leads_to", 8240 + "weight": 1.0, 8241 + "rationale": "Implementation completed successfully", 8242 + "created_at": "2025-12-28T19:14:24.961595600-05:00" 8012 8243 } 8013 8244 ] 8014 8245 }
+173
packages/extension/FIREFOX.md
··· 1 + # Firefox Extension Installation Guide 2 + 3 + The ATlast Importer extension now supports both Chrome and Firefox! 4 + 5 + ## Building for Firefox 6 + 7 + The build system automatically creates both Chrome and Firefox versions: 8 + 9 + ```bash 10 + pnpm run build # Development build for both browsers 11 + pnpm run build:prod # Production build for both browsers 12 + ``` 13 + 14 + Output directories: 15 + - `dist/chrome/` - Chrome/Edge version (Manifest V3 with service worker) 16 + - `dist/firefox/` - Firefox version (Manifest V3 with scripts array) 17 + 18 + ## Installing in Firefox (Development) 19 + 20 + ### Option 1: Temporary Installation (for testing) 21 + 22 + 1. Open Firefox 23 + 2. Navigate to `about:debugging#/runtime/this-firefox` 24 + 3. Click "Load Temporary Add-on..." 25 + 4. Navigate to `packages/extension/dist/firefox/` 26 + 5. Select the `manifest.json` file 27 + 28 + **Note:** Temporary extensions are removed when Firefox restarts. 29 + 30 + ### Option 2: Loading from ZIP (for distribution) 31 + 32 + 1. Build the production version: 33 + ```bash 34 + pnpm run build:prod 35 + pnpm run package:firefox 36 + ``` 37 + 38 + 2. This creates `dist/firefox.zip` 39 + 40 + 3. For testing: 41 + - Go to `about:debugging#/runtime/this-firefox` 42 + - Click "Load Temporary Add-on..." 43 + - Select the `firefox.zip` file 44 + 45 + 4. For publishing: 46 + - Submit `firefox.zip` to [addons.mozilla.org](https://addons.mozilla.org/developers/) 47 + 48 + ## Key Differences from Chrome Version 49 + 50 + ### Manifest Differences 51 + 52 + **Chrome (`manifest.chrome.json`):** 53 + ```json 54 + { 55 + "manifest_version": 3, 56 + "background": { 57 + "service_worker": "background/service-worker.js", 58 + "type": "module" 59 + } 60 + } 61 + ``` 62 + 63 + **Firefox (`manifest.firefox.json`):** 64 + ```json 65 + { 66 + "manifest_version": 3, 67 + "background": { 68 + "scripts": ["background/service-worker.js"], 69 + "type": "module" 70 + }, 71 + "browser_specific_settings": { 72 + "gecko": { 73 + "id": "atlast-importer@byarielm.fyi", 74 + "strict_min_version": "109.0" 75 + } 76 + } 77 + } 78 + ``` 79 + 80 + ### Cross-Browser Compatibility 81 + 82 + - All code uses `webextension-polyfill` library 83 + - Chrome-specific `chrome.*` APIs replaced with unified `browser.*` API 84 + - Promise-based instead of callback-based 85 + - Single codebase works across both browsers 86 + 87 + ### Requirements 88 + 89 + - **Firefox:** Version 109+ (for Manifest V3 support) 90 + - **Chrome/Edge:** Latest version 91 + 92 + ## Testing 93 + 94 + After loading the extension in Firefox: 95 + 96 + 1. Navigate to Twitter/X Following page (e.g., `https://twitter.com/username/following`) 97 + 2. Click the extension icon in the toolbar 98 + 3. The popup should show "Ready to scan" state 99 + 4. Click "Start Scan" to scrape usernames 100 + 5. Click "Open on ATlast" to upload results 101 + 102 + ## Debugging 103 + 104 + ### View Console Logs 105 + 106 + **Background Script:** 107 + - Go to `about:debugging#/runtime/this-firefox` 108 + - Find "ATlast Importer" in the list 109 + - Click "Inspect" 110 + 111 + **Popup:** 112 + - Right-click extension icon → "Inspect Extension" 113 + 114 + **Content Script:** 115 + - Open DevTools on Twitter/X page (F12) 116 + - Look for `[ATlast]` prefixed logs in Console 117 + 118 + ### Common Issues 119 + 120 + 1. **Extension not loading:** 121 + - Check Firefox version is 109+ 122 + - Ensure manifest.json is valid 123 + - Check browser console for errors 124 + 125 + 2. **Scan not starting:** 126 + - Verify you're on Twitter/X Following page 127 + - Check content script is injected (look for console logs) 128 + - Ensure page is fully loaded 129 + 130 + 3. **"Server offline" message:** 131 + - Make sure dev server is running (`netlify dev`) 132 + - Check API URL in extension settings 133 + 134 + ## Packaging for Distribution 135 + 136 + Create production builds for both browsers: 137 + 138 + ```bash 139 + pnpm run package:prod 140 + ``` 141 + 142 + This creates: 143 + - `dist/chrome.zip` - Ready for Chrome Web Store 144 + - `dist/firefox.zip` - Ready for Firefox Add-ons 145 + 146 + ## Development Workflow 147 + 148 + ```bash 149 + # Watch mode (auto-rebuild on changes) 150 + pnpm run dev 151 + 152 + # In Firefox: 153 + # 1. about:debugging → Reload extension after each rebuild 154 + # 2. Or use web-ext for auto-reload: 155 + 156 + npx web-ext run --source-dir=dist/firefox 157 + ``` 158 + 159 + ## Differences You Might Notice 160 + 161 + 1. **Background page persistence:** 162 + - Chrome: Service worker (non-persistent) 163 + - Firefox: Scripts array (similar behavior in MV3) 164 + 165 + 2. **API behavior:** 166 + - Firefox: Native Promise support 167 + - Chrome: Promises via polyfill 168 + 169 + 3. **Extension ID:** 170 + - Chrome: Auto-generated 171 + - Firefox: Explicitly set as `atlast-importer@byarielm.fyi` 172 + 173 + Both versions use the same source code and should behave identically!
+61 -42
packages/extension/build.js
··· 21 21 console.log(`🔗 API URL: ${ATLAST_API_URL}`); 22 22 23 23 // Clean dist directory 24 - const distDir = path.join(__dirname, 'dist', 'chrome'); 25 - if (fs.existsSync(distDir)) { 26 - fs.rmSync(distDir, { recursive: true }); 24 + const distBaseDir = path.join(__dirname, 'dist'); 25 + if (fs.existsSync(distBaseDir)) { 26 + fs.rmSync(distBaseDir, { recursive: true }); 27 27 } 28 - fs.mkdirSync(distDir, { recursive: true }); 28 + fs.mkdirSync(distBaseDir, { recursive: true }); 29 29 30 30 // Build configuration base 31 31 const buildConfigBase = { ··· 38 38 '__ATLAST_API_URL__': JSON.stringify(ATLAST_API_URL), 39 39 '__BUILD_MODE__': JSON.stringify(mode), 40 40 }, 41 + // Include webextension-polyfill in the bundle 42 + external: [], 41 43 }; 42 44 43 - // Build scripts 44 - const scripts = [ 45 - { 46 - ...buildConfigBase, 47 - entryPoints: ['src/content/index.ts'], 48 - outfile: path.join(distDir, 'content', 'index.js'), 49 - }, 50 - { 51 - ...buildConfigBase, 52 - entryPoints: ['src/background/service-worker.ts'], 53 - outfile: path.join(distDir, 'background', 'service-worker.js'), 54 - }, 55 - { 56 - ...buildConfigBase, 57 - entryPoints: ['src/popup/popup.ts'], 58 - outfile: path.join(distDir, 'popup', 'popup.js'), 59 - }, 60 - ]; 45 + // Build scripts for a specific browser 46 + function getScripts(browser) { 47 + const distDir = path.join(distBaseDir, browser); 48 + return [ 49 + { 50 + ...buildConfigBase, 51 + entryPoints: ['src/content/index.ts'], 52 + outfile: path.join(distDir, 'content', 'index.js'), 53 + }, 54 + { 55 + ...buildConfigBase, 56 + entryPoints: ['src/background/service-worker.ts'], 57 + outfile: path.join(distDir, 'background', 'service-worker.js'), 58 + }, 59 + { 60 + ...buildConfigBase, 61 + entryPoints: ['src/popup/popup.ts'], 62 + outfile: path.join(distDir, 'popup', 'popup.js'), 63 + }, 64 + ]; 65 + } 61 66 62 67 // Build function 63 68 async function build() { 64 69 try { 65 - console.log('🔨 Building extension...'); 70 + console.log('🔨 Building extension for Chrome and Firefox...'); 71 + 72 + const browsers = ['chrome', 'firefox']; 66 73 67 - // Build all scripts 68 - for (const config of scripts) { 69 - if (watch) { 70 - const ctx = await esbuild.context(config); 71 - await ctx.watch(); 72 - console.log(`👀 Watching ${path.basename(config.entryPoints[0])}...`); 73 - } else { 74 - await esbuild.build(config); 75 - console.log(`✅ Built ${path.basename(config.entryPoints[0])}`); 74 + for (const browser of browsers) { 75 + console.log(`\n📦 Building ${browser} version...`); 76 + const scripts = getScripts(browser); 77 + 78 + // Build all scripts 79 + for (const config of scripts) { 80 + if (watch) { 81 + const ctx = await esbuild.context(config); 82 + await ctx.watch(); 83 + console.log(`👀 Watching ${browser}/${path.basename(config.entryPoints[0])}...`); 84 + } else { 85 + await esbuild.build(config); 86 + console.log(`✅ Built ${browser}/${path.basename(config.entryPoints[0])}`); 87 + } 76 88 } 77 - } 78 89 79 - // Copy static files 80 - copyStaticFiles(); 90 + // Copy static files 91 + copyStaticFiles(browser); 81 92 82 - // Process CSS with Tailwind 83 - await processCSS(); 93 + // Process CSS with Tailwind 94 + await processCSS(browser); 95 + } 84 96 85 97 if (!watch) { 86 - console.log('✨ Build complete!'); 98 + console.log('\n✨ Build complete for both browsers!'); 87 99 } 88 100 } catch (error) { 89 101 console.error('❌ Build failed:', error); ··· 92 104 } 93 105 94 106 // Process CSS with PostCSS (Tailwind + Autoprefixer) 95 - async function processCSS() { 107 + async function processCSS(browser) { 96 108 const cssPath = path.join(__dirname, 'src/popup/popup.css'); 109 + const distDir = path.join(distBaseDir, browser); 97 110 const outputPath = path.join(distDir, 'popup/popup.css'); 98 111 99 112 const css = fs.readFileSync(cssPath, 'utf8'); ··· 121 134 } 122 135 123 136 // Copy static files 124 - function copyStaticFiles() { 137 + function copyStaticFiles(browser) { 138 + const distDir = path.join(distBaseDir, browser); 139 + 125 140 const filesToCopy = [ 126 - { from: 'manifest.json', to: 'manifest.json' }, 141 + { from: `manifest.${browser}.json`, to: 'manifest.json', fallback: 'manifest.json' }, 127 142 { from: 'src/popup/popup.html', to: 'popup/popup.html' }, 128 143 ]; 129 144 130 145 for (const file of filesToCopy) { 131 - const srcPath = path.join(__dirname, file.from); 146 + // Try to use browser-specific file first, fall back to default 147 + let srcPath = path.join(__dirname, file.from); 148 + if (file.fallback && !fs.existsSync(srcPath)) { 149 + srcPath = path.join(__dirname, file.fallback); 150 + } 132 151 const destPath = path.join(distDir, file.to); 133 152 134 153 // Create directory if it doesn't exist
+44
packages/extension/manifest.chrome.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "ATlast Importer", 4 + "version": "1.0.0", 5 + "description": "Import your Twitter/X follows to find them on Bluesky", 6 + "permissions": [ 7 + "activeTab", 8 + "storage" 9 + ], 10 + "host_permissions": [ 11 + "https://twitter.com/*", 12 + "https://x.com/*", 13 + "http://127.0.0.1:8888/*", 14 + "http://localhost:8888/*", 15 + "https://atlast.byarielm.fyi/*" 16 + ], 17 + "background": { 18 + "service_worker": "background/service-worker.js", 19 + "type": "module" 20 + }, 21 + "content_scripts": [ 22 + { 23 + "matches": [ 24 + "https://twitter.com/*", 25 + "https://x.com/*" 26 + ], 27 + "js": ["content/index.js"], 28 + "run_at": "document_idle" 29 + } 30 + ], 31 + "action": { 32 + "default_popup": "popup/popup.html", 33 + "default_icon": { 34 + "16": "assets/icon-16.png", 35 + "48": "assets/icon-48.png", 36 + "128": "assets/icon-128.png" 37 + } 38 + }, 39 + "icons": { 40 + "16": "assets/icon-16.png", 41 + "48": "assets/icon-48.png", 42 + "128": "assets/icon-128.png" 43 + } 44 + }
+50
packages/extension/manifest.firefox.json
··· 1 + { 2 + "manifest_version": 3, 3 + "name": "ATlast Importer", 4 + "version": "1.0.0", 5 + "description": "Import your Twitter/X follows to find them on Bluesky", 6 + "permissions": [ 7 + "activeTab", 8 + "storage" 9 + ], 10 + "host_permissions": [ 11 + "https://twitter.com/*", 12 + "https://x.com/*", 13 + "http://127.0.0.1:8888/*", 14 + "http://localhost:8888/*", 15 + "https://atlast.byarielm.fyi/*" 16 + ], 17 + "background": { 18 + "scripts": ["background/service-worker.js"], 19 + "type": "module" 20 + }, 21 + "content_scripts": [ 22 + { 23 + "matches": [ 24 + "https://twitter.com/*", 25 + "https://x.com/*" 26 + ], 27 + "js": ["content/index.js"], 28 + "run_at": "document_idle" 29 + } 30 + ], 31 + "action": { 32 + "default_popup": "popup/popup.html", 33 + "default_icon": { 34 + "16": "assets/icon-16.png", 35 + "48": "assets/icon-48.png", 36 + "128": "assets/icon-128.png" 37 + } 38 + }, 39 + "icons": { 40 + "16": "assets/icon-16.png", 41 + "48": "assets/icon-48.png", 42 + "128": "assets/icon-128.png" 43 + }, 44 + "browser_specific_settings": { 45 + "gecko": { 46 + "id": "atlast-importer@byarielm.fyi", 47 + "strict_min_version": "109.0" 48 + } 49 + } 50 + }
+6 -2
packages/extension/package.json
··· 9 9 "build:prod": "node build.js --prod", 10 10 "dev": "node build.js --watch", 11 11 "package:chrome": "cd dist/chrome && zip -r ../chrome.zip .", 12 - "package:prod": "npm run build:prod && npm run package:chrome" 12 + "package:firefox": "cd dist/firefox && zip -r ../firefox.zip .", 13 + "package:all": "pnpm run package:chrome && pnpm run package:firefox", 14 + "package:prod": "pnpm run build:prod && pnpm run package:all" 13 15 }, 14 16 "dependencies": { 15 - "@atlast/shared": "workspace:*" 17 + "@atlast/shared": "workspace:*", 18 + "webextension-polyfill": "^0.12.0" 16 19 }, 17 20 "devDependencies": { 18 21 "@types/chrome": "^0.0.256", 22 + "@types/webextension-polyfill": "^0.12.4", 19 23 "autoprefixer": "^10.4.23", 20 24 "cssnano": "^7.1.2", 21 25 "esbuild": "^0.19.11",
+2 -1
packages/extension/src/background/service-worker.ts
··· 1 + import browser from 'webextension-polyfill'; 1 2 import { 2 3 MessageType, 3 4 onMessage, ··· 150 151 /** 151 152 * Log extension installation 152 153 */ 153 - chrome.runtime.onInstalled.addListener((details) => { 154 + browser.runtime.onInstalled.addListener((details) => { 154 155 console.log('[Background] Extension installed:', details.reason); 155 156 156 157 if (details.reason === 'install') {
+3 -1
packages/extension/src/lib/api-client.ts
··· 2 2 * ATlast API client for extension 3 3 */ 4 4 5 + import browser from 'webextension-polyfill'; 6 + 5 7 // These are replaced at build time by esbuild 6 8 declare const __ATLAST_API_URL__: string; 7 9 declare const __BUILD_MODE__: string; ··· 67 69 * Get extension version from manifest 68 70 */ 69 71 export function getExtensionVersion(): string { 70 - return chrome.runtime.getManifest().version; 72 + return browser.runtime.getManifest().version; 71 73 } 72 74 73 75 /**
+6 -5
packages/extension/src/lib/messaging.ts
··· 1 + import browser from 'webextension-polyfill'; 1 2 import type { ScraperProgress, ScraperResult } from '../content/scrapers/base-scraper.js'; 2 3 3 4 /** ··· 87 88 * Send message to background script 88 89 */ 89 90 export function sendToBackground<T = any>(message: Message): Promise<T> { 90 - return chrome.runtime.sendMessage(message); 91 + return browser.runtime.sendMessage(message); 91 92 } 92 93 93 94 /** 94 95 * Send message to active tab's content script 95 96 */ 96 97 export async function sendToContent(message: Message): Promise<any> { 97 - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); 98 + const [tab] = await browser.tabs.query({ active: true, currentWindow: true }); 98 99 if (!tab.id) { 99 100 throw new Error('No active tab found'); 100 101 } 101 - return chrome.tabs.sendMessage(tab.id, message); 102 + return browser.tabs.sendMessage(tab.id, message); 102 103 } 103 104 104 105 /** 105 106 * Listen for messages 106 107 */ 107 108 export function onMessage( 108 - handler: (message: Message, sender: chrome.runtime.MessageSender) => any | Promise<any> 109 + handler: (message: Message, sender: browser.Runtime.MessageSender) => any | Promise<any> 109 110 ): void { 110 - chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 111 + browser.runtime.onMessage.addListener((message, sender, sendResponse) => { 111 112 const result = handler(message, sender); 112 113 113 114 // Handle async handlers
+4 -3
packages/extension/src/lib/storage.ts
··· 1 + import browser from 'webextension-polyfill'; 1 2 import type { ExtensionState } from './messaging.js'; 2 3 3 4 /** ··· 11 12 * Get extension state from storage 12 13 */ 13 14 export async function getState(): Promise<ExtensionState> { 14 - const result = await chrome.storage.local.get(STORAGE_KEYS.STATE); 15 + const result = await browser.storage.local.get(STORAGE_KEYS.STATE); 15 16 return result[STORAGE_KEYS.STATE] || { status: 'idle' }; 16 17 } 17 18 ··· 19 20 * Save extension state to storage 20 21 */ 21 22 export async function setState(state: ExtensionState): Promise<void> { 22 - await chrome.storage.local.set({ [STORAGE_KEYS.STATE]: state }); 23 + await browser.storage.local.set({ [STORAGE_KEYS.STATE]: state }); 23 24 } 24 25 25 26 /** 26 27 * Clear extension state 27 28 */ 28 29 export async function clearState(): Promise<void> { 29 - await chrome.storage.local.remove(STORAGE_KEYS.STATE); 30 + await browser.storage.local.remove(STORAGE_KEYS.STATE); 30 31 }
+4 -3
packages/extension/src/popup/popup.ts
··· 1 + import browser from 'webextension-polyfill'; 1 2 import { 2 3 MessageType, 3 4 sendToBackground, ··· 167 168 // Open ATlast at results page with upload data 168 169 const { getApiUrl } = await import('../lib/api-client.js'); 169 170 const resultsUrl = `${getApiUrl()}${response.redirectUrl}`; 170 - chrome.tabs.create({ url: resultsUrl }); 171 + browser.tabs.create({ url: resultsUrl }); 171 172 172 173 } catch (error) { 173 174 console.error('[Popup] Error uploading:', error); ··· 281 282 282 283 // Set up login buttons 283 284 elements.btnOpenAtlast.addEventListener('click', () => { 284 - chrome.tabs.create({ url: getApiUrl() }); 285 + browser.tabs.create({ url: getApiUrl() }); 285 286 }); 286 287 287 288 elements.btnRetryLogin.addEventListener('click', async () => { ··· 322 323 }); 323 324 324 325 // Listen for storage changes (when background updates state) 325 - chrome.storage.onChanged.addListener((changes, areaName) => { 326 + browser.storage.onChanged.addListener((changes, areaName) => { 326 327 if (areaName === 'local' && changes.extensionState) { 327 328 const newState = changes.extensionState.newValue; 328 329 console.log('[Popup] 🔄 Storage changed, new state:', newState);