├── .cursorrules ├── .eslintrc.json ├── .gitignore ├── .nvmrc ├── .prettierrc ├── LICENSE ├── PRIVACY.md ├── README.md ├── eslint.config.js ├── package.json ├── public ├── icons │ └── icon.svg └── manifest.json ├── rules └── rules.json ├── scripts └── build.ts ├── src ├── assets │ └── react.svg ├── background │ ├── constants.ts │ ├── index.ts │ └── utils.ts ├── components │ ├── Toast.tsx │ └── fileIcon.tsx ├── pages │ ├── desktop │ │ ├── App.tsx │ │ ├── index.html │ │ └── index.tsx │ └── settings │ │ ├── App.tsx │ │ ├── index.html │ │ └── index.tsx ├── popup │ ├── App.tsx │ ├── components │ │ ├── CrxFileTree.tsx │ │ ├── LoadingScreen.tsx │ │ └── SourceMapTable.tsx │ ├── index.html │ └── index.tsx ├── storage │ └── database.ts ├── theme │ └── index.ts ├── types │ ├── chrome.d.ts │ ├── env.d.ts │ └── index.ts └── utils │ ├── browser-polyfill.ts │ ├── crx-to-zip.ts │ ├── desktopApp.ts │ ├── format.ts │ ├── isExtensionPage.ts │ ├── parseCrxFile.ts │ ├── sourceMapDownloader.ts │ └── sourceMapUtils.ts ├── tsconfig.json └── yarn.lock /.cursorrules: -------------------------------------------------------------------------------- 1 | You are an expert in Chrome Extension Development, JavaScript, TypeScript, HTML, CSS, Shadcn UI, Radix UI, Tailwind and Web APIs. 2 | 3 | Code Style and Structure: 4 | 5 | - Write concise, technical JavaScript/TypeScript code with accurate examples 6 | - Use modern JavaScript features and best practices 7 | - Prefer functional programming patterns; minimize use of classes 8 | - Use descriptive variable names (e.g., isExtensionEnabled, hasPermission) 9 | - Structure files: manifest.json, background scripts, content scripts, popup scripts, options page 10 | 11 | Naming Conventions: 12 | 13 | - Use lowercase with underscores for file names (e.g., content_script.js, background_worker.js) 14 | - Use camelCase for function and variable names 15 | - Use PascalCase for class names (if used) 16 | 17 | TypeScript Usage: 18 | 19 | - Encourage TypeScript for type safety and better developer experience 20 | - Use interfaces for defining message structures and API responses 21 | - Leverage TypeScript's union types and type guards for runtime checks 22 | 23 | Extension Architecture: 24 | 25 | - Implement a clear separation of concerns between different extension components 26 | - Use message passing for communication between different parts of the extension 27 | - Implement proper state management using chrome.storage API 28 | 29 | Manifest and Permissions: 30 | 31 | - Use the latest manifest version (v3) unless there's a specific need for v2 32 | - Follow the principle of least privilege for permissions 33 | - Implement optional permissions where possible 34 | 35 | Security and Privacy: 36 | 37 | - Implement Content Security Policy (CSP) in manifest.json 38 | - Use HTTPS for all network requests 39 | - Sanitize user inputs and validate data from external sources 40 | - Implement proper error handling and logging 41 | 42 | UI and Styling: 43 | 44 | - Create responsive designs for popup and options pages 45 | - Use CSS Grid or Flexbox for layouts 46 | - Implement consistent styling across all extension UI elements 47 | 48 | Performance Optimization: 49 | 50 | - Minimize resource usage in background scripts 51 | - Use event pages instead of persistent background pages when possible 52 | - Implement lazy loading for non-critical extension features 53 | - Optimize content scripts to minimize impact on web page performance 54 | 55 | Browser API Usage: 56 | 57 | - Utilize chrome.* APIs effectively (e.g., chrome.tabs, chrome.storage, chrome.runtime) 58 | - Implement proper error handling for all API calls 59 | - Use chrome.alarms for scheduling tasks instead of setInterval 60 | 61 | Cross-browser Compatibility: 62 | 63 | - Use WebExtensions API for cross-browser support where possible 64 | - Implement graceful degradation for browser-specific features 65 | 66 | Testing and Debugging: 67 | 68 | - Utilize Chrome DevTools for debugging 69 | - Implement unit tests for core extension functionality 70 | - Use Chrome's built-in extension loading for testing during development 71 | 72 | Context-Aware Development: 73 | 74 | - Always consider the whole project context when providing suggestions or generating code 75 | - Avoid duplicating existing functionality or creating conflicting implementations 76 | - Ensure that new code integrates seamlessly with the existing project structure and architecture 77 | - Before adding new features or modifying existing ones, review the current project state to maintain consistency and avoid redundancy 78 | - When answering questions or providing solutions, take into account previously discussed or implemented features to prevent contradictions or repetitions 79 | 80 | Code Output: 81 | 82 | - When providing code, always output the entire file content, not just new or modified parts 83 | - Include all necessary imports, declarations, and surrounding code to ensure the file is complete and functional 84 | - Provide comments or explanations for significant changes or additions within the file 85 | - If the file is too large to reasonably include in full, provide the most relevant complete section and clearly indicate where it fits in the larger file structure 86 | 87 | Follow Chrome Extension documentation for best practices, security guidelines, and API usage -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2020": true, 6 | "webextensions": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react-hooks/recommended" 12 | ], 13 | "ignorePatterns": [ 14 | "dist", 15 | ".eslintrc.json" 16 | ], 17 | "parser": "@typescript-eslint/parser", 18 | "plugins": [ 19 | "react-refresh", 20 | "@typescript-eslint" 21 | ], 22 | "rules": { 23 | "react-refresh/only-export-components": [ 24 | "warn", 25 | { 26 | "allowConstantExport": true 27 | } 28 | ], 29 | "@typescript-eslint/no-explicit-any": "warn", 30 | "@typescript-eslint/no-unused-vars": [ 31 | "warn", 32 | { 33 | "argsIgnorePattern": "^_" 34 | } 35 | ], 36 | "no-console": [ 37 | "warn", 38 | { 39 | "allow": [ 40 | "warn", 41 | "error" 42 | ] 43 | } 44 | ] 45 | }, 46 | "settings": { 47 | "react": { 48 | "version": "detect" 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | # TypeScript 27 | *.tsbuildinfo 28 | sample-projects/* 29 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "singleQuote": true, 6 | "trailingComma": "es5", 7 | "bracketSpacing": true, 8 | "bracketSameLine": false, 9 | "arrowParens": "avoid", 10 | "endOfLine": "lf" 11 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Source Detector 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /PRIVACY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy for Source Detector 2 | 3 | Last updated: March 2024 4 | 5 | ## Overview 6 | Source Detector is committed to protecting your privacy. This privacy policy explains how our browser extension handles data collection, storage, and usage. 7 | 8 | ## Data Collection and Storage 9 | 10 | ### What We Collect 11 | - Source map files detected from websites you visit 12 | - Chrome extension (CRX) files you choose to analyze 13 | - Extension settings and preferences 14 | - File metadata (size, type, origin URL) 15 | 16 | ### How Data is Stored 17 | - All data is stored locally on your device 18 | - No data is transmitted to external servers 19 | - No personal information is collected 20 | - No browsing history is tracked 21 | 22 | ### Storage Duration 23 | - Data remains stored until you choose to delete it 24 | - You can clear all stored data through the extension settings 25 | 26 | ## Data Usage 27 | 28 | ### How We Use the Data 29 | - To display source maps and CRX files for analysis 30 | - To maintain a history of detected files 31 | - To organize files by website 32 | - To provide offline access to saved files 33 | 34 | ### Data Sharing 35 | - We do not share any data with third parties 36 | - We do not collect or transmit data to external servers 37 | - All operations are performed locally on your device 38 | 39 | ## Permissions 40 | 41 | ### Why We Need Permissions 42 | - Storage: To save detected files and settings locally 43 | - ActiveTab: To detect files on the current webpage 44 | - Downloads: To save files to your computer 45 | - WebRequest: To detect source map references 46 | - Host Permissions: To access source files from their original locations 47 | 48 | ## User Control 49 | 50 | ### Your Rights 51 | - View all stored data through the extension interface 52 | - Clear all data with one click 53 | 54 | ### Data Security 55 | - All operations are performed locally 56 | - No external network connections are made 57 | - No authentication or account required 58 | 59 | ## Changes to This Policy 60 | We may update this privacy policy from time to time. We will notify users of any material changes through the extension's update notes. 61 | 62 | ## Contact 63 | If you have questions about this privacy policy, please create an issue in our GitHub repository. 64 | 65 | ## Consent 66 | By using Source Detector, you consent to this privacy policy. 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Source Detector 2 | 3 | Source Detector is a Chrome extension that automatically collects and views source maps from websites. 4 | 5 | ## How to local development 6 | 7 | 1. Clone the repository 8 | 2. Install dependencies 9 | 3. Run `npm run dev` 10 | 4. Open Chrome and install the extension from the `dist` folder 11 | 1. Open `chrome://extensions` 12 | 2. Enable "Developer mode" 13 | 3. Click "Load unpacked" 14 | 4. Select the `dist` folder 15 | 16 | ## Functions 17 | 18 | - Displays a badge with the number when detected source files 19 | - Collects source map files from a website 20 | - .map files 21 | - Collects CRX files from a extension website or a extension page 22 | - Download source map files and parsed files 23 | - Download CRX files and parsed files 24 | - Show View All Pages in desktop app 25 | - Show history source map files in desktop app 26 | - Show source map files from x domains in desktop app 27 | - Show history CRX files in desktop app 28 | - Show CRX files from x domains in desktop app 29 | 30 | ## TODO 31 | - [ ] i18n 32 | - [ ] inline source map files 33 | - [ ] UI improvements 34 | - [ ] Open in desktop app -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "source-detector", 3 | "private": true, 4 | "version": "1.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "node --watch --import tsx scripts/build.ts --watch --browser=chrome", 8 | "dev:chrome": "node --watch --import tsx scripts/build.ts --watch --browser=chrome", 9 | "dev:firefox": "node --watch --import tsx scripts/build.ts --watch --browser=firefox", 10 | "build": "tsx scripts/build.ts --browser=chrome", 11 | "build:chrome": "tsx scripts/build.ts --browser=chrome", 12 | "build:firefox": "tsx scripts/build.ts --browser=firefox", 13 | "build:all": "npm run build:chrome && npm run build:firefox", 14 | "clean": "rimraf dist" 15 | }, 16 | "dependencies": { 17 | "@emotion/react": "^11.13.5", 18 | "@emotion/styled": "^11.13.5", 19 | "@mui/icons-material": "^6.1.10", 20 | "@mui/lab": "^6.0.0-beta.18", 21 | "@mui/material": "^6.1.10", 22 | "@mui/x-tree-view": "^7.23.2", 23 | "@types/crypto-js": "^4.2.2", 24 | "@types/webextension-polyfill": "^0.12.1", 25 | "crypto-js": "^4.2.0", 26 | "dexie": "^4.0.1", 27 | "jszip": "^3.10.1", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-router-dom": "^7.0.2", 31 | "source-map-js": "^1.2.1", 32 | "webextension-polyfill": "^0.10.0" 33 | }, 34 | "devDependencies": { 35 | "@eslint/js": "^9.15.0", 36 | "@types/archiver": "^6.0.3", 37 | "@types/chrome": "^0.0.287", 38 | "@types/fs-extra": "^11.0.4", 39 | "@types/node": "^22.10.1", 40 | "@types/react": "^18.3.14", 41 | "@types/react-dom": "^18.3.2", 42 | "@typescript-eslint/eslint-plugin": "^8.17.0", 43 | "@typescript-eslint/parser": "^8.17.0", 44 | "archiver": "^5.3.1", 45 | "esbuild": "^0.20.1", 46 | "eslint": "^9.16.0", 47 | "eslint-plugin-react-hooks": "^5.0.0", 48 | "eslint-plugin-react-refresh": "^0.4.14", 49 | "fs-extra": "^11.2.0", 50 | "globals": "^15.12.0", 51 | "prettier": "^3.4.2", 52 | "rimraf": "^5.0.5", 53 | "sharp": "^0.33.5", 54 | "tsx": "^4.7.1", 55 | "typescript": "^5.3.3", 56 | "typescript-eslint": "^8.15.0" 57 | }, 58 | "description": "Chrome extension for automatically collecting and viewing source maps", 59 | "main": "eslint.config.js", 60 | "keywords": [ 61 | "chrome-extension", 62 | "sourcemap", 63 | "developer-tools" 64 | ], 65 | "author": "", 66 | "license": "MIT" 67 | } -------------------------------------------------------------------------------- /public/icons/icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Source Detector", 4 | "version": "1.0.0", 5 | "description": "A powerful tool for developers to automatically detect, collect, and analyze source maps and CRX files from websites", 6 | "permissions": [ 7 | "tabs", 8 | "webRequest", 9 | "declarativeNetRequest" 10 | ], 11 | "host_permissions": [ 12 | "" 13 | ], 14 | "declarative_net_request": { 15 | "rule_resources": [ 16 | { 17 | "id": "ruleset_1", 18 | "enabled": true, 19 | "path": "rules/rules.json" 20 | } 21 | ] 22 | }, 23 | "background": { 24 | "service_worker": "background/index.js", 25 | "type": "module" 26 | }, 27 | "action": { 28 | "default_popup": "popup/index.html", 29 | "default_icon": { 30 | "16": "icons/icon-16.png", 31 | "48": "icons/icon-48.png", 32 | "128": "icons/icon-128.png" 33 | } 34 | }, 35 | "icons": { 36 | "16": "icons/icon-16.png", 37 | "48": "icons/icon-48.png", 38 | "128": "icons/icon-128.png" 39 | }, 40 | "options_page": "pages/settings/index.html", 41 | "web_accessible_resources": [ 42 | { 43 | "resources": [ 44 | "js/*.js", 45 | "js/**/*.js", 46 | "js/**/*.js.map", 47 | "assets/*", 48 | "assets/**/*", 49 | "pages/*", 50 | "pages/**/*", 51 | "vs/*", 52 | "vs/**/*" 53 | ], 54 | "matches": [ 55 | "" 56 | ] 57 | } 58 | ], 59 | "content_security_policy": { 60 | "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'", 61 | "sandbox": "sandbox allow-scripts allow-forms allow-popups allow-modals; script-src 'self' 'unsafe-inline' 'unsafe-eval'; worker-src 'self'" 62 | }, 63 | "minimum_chrome_version": "88" 64 | } -------------------------------------------------------------------------------- /rules/rules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "id": 1, 4 | "priority": 1, 5 | "action": { 6 | "type": "modifyHeaders", 7 | "responseHeaders": [ 8 | { 9 | "header": "Content-Security-Policy", 10 | "operation": "remove" 11 | } 12 | ] 13 | }, 14 | "condition": { 15 | "urlFilter": "*", 16 | "resourceTypes": ["script", "stylesheet"] 17 | } 18 | } 19 | ] -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import fs from 'fs-extra'; 3 | import { resolve } from 'path'; 4 | import sharp from 'sharp'; 5 | import archiver from 'archiver'; 6 | import { ArchiverError } from 'archiver'; 7 | 8 | const BUILD_DIR = 'dist'; 9 | const SRC_DIR = 'src'; 10 | const RULES_DIR = 'rules'; // Add this line 11 | 12 | function formatDuration(duration: number): string { 13 | if (duration < 1000) { 14 | return `${duration}ms`; 15 | } 16 | return `${(duration / 1000).toFixed(2)}s`; 17 | } 18 | 19 | async function convertIcons() { 20 | const sizes = [16, 48, 128]; 21 | const sourceIcon = resolve('public/icons/icon.svg'); 22 | const targetDir = resolve(BUILD_DIR, 'icons'); 23 | 24 | // Ensure icons directory exists 25 | await fs.ensureDir(targetDir); 26 | 27 | // Convert icons to all sizes 28 | await Promise.all(sizes.map(size => 29 | sharp(sourceIcon) 30 | .resize(size, size) 31 | .toFile(resolve(targetDir, `icon-${size}.png`)) 32 | )); 33 | 34 | console.log('📦 Generated icon files in different sizes'); 35 | } 36 | 37 | async function build(isWatch = false, browser = 'chrome') { 38 | const startTime = Date.now(); 39 | console.log(`🚀 Building extension for ${browser}...`); 40 | 41 | // Add Firefox-specific manifest modifications 42 | if (browser === 'firefox') { 43 | const manifestContent = JSON.parse( 44 | await fs.readFile(resolve('public', 'manifest.json'), 'utf-8') 45 | ); 46 | 47 | // Firefox-specific changes 48 | manifestContent.browser_specific_settings = { 49 | "gecko": { 50 | "id": "wavesun.org@gmail.com", 51 | "strict_min_version": "109.0" 52 | } 53 | }; 54 | 55 | // Firefox uses different background script format 56 | manifestContent.background = { 57 | "scripts": ["background/index.js"], 58 | "type": "module" 59 | }; 60 | 61 | console.log(manifestContent.background); 62 | 63 | // Remove service_worker if it exists 64 | if (manifestContent.background?.service_worker) { 65 | delete manifestContent.background.service_worker; 66 | } 67 | console.log(manifestContent.background); 68 | 69 | await fs.writeFile( 70 | resolve(BUILD_DIR, 'manifest.json'), 71 | JSON.stringify(manifestContent, null, 2), 72 | 'utf-8' 73 | ); 74 | } 75 | 76 | // Add icon conversion before other build steps 77 | await convertIcons(); 78 | 79 | // Copy rules directory to dist 80 | await fs.copy(RULES_DIR, resolve(BUILD_DIR, RULES_DIR)); 81 | console.log(`📦 Copied ${RULES_DIR} to ${BUILD_DIR}`); 82 | 83 | try { 84 | // Build context for watch mode 85 | const ctx = await esbuild.context({ 86 | entryPoints: { 87 | 'background/index': resolve(SRC_DIR, 'background/index.ts'), 88 | 'popup/index': resolve(SRC_DIR, 'popup/index.tsx'), 89 | 'pages/settings/index': resolve(SRC_DIR, 'pages/settings/index.tsx'), 90 | 'pages/desktop/index': resolve(SRC_DIR, 'pages/desktop/index.tsx'), 91 | }, 92 | bundle: true, 93 | format: 'iife', 94 | outdir: BUILD_DIR, 95 | sourcemap: true, 96 | target: ['chrome88'], 97 | loader: { 98 | '.tsx': 'tsx', 99 | '.ts': 'ts', 100 | '.jsx': 'jsx', 101 | '.js': 'js', 102 | '.svg': 'file', 103 | '.png': 'file', 104 | '.css': 'css', 105 | '.ttf': 'file', 106 | '.woff': 'file', 107 | '.woff2': 'file', 108 | '.eot': 'file', 109 | }, 110 | define: { 111 | 'process.env.NODE_ENV': '"production"', 112 | 'global': 'globalThis' 113 | }, 114 | assetNames: 'assets/[name]-[hash]', 115 | publicPath: '/', 116 | metafile: true, 117 | logLevel: 'info' 118 | }); 119 | 120 | // Copy static files 121 | await fs.copy('public', BUILD_DIR, { 122 | filter: (src) => !src.includes('manifest.json'), 123 | overwrite: true 124 | }); 125 | 126 | // Copy HTML files 127 | await fs.copy( 128 | resolve(SRC_DIR, 'popup/index.html'), 129 | resolve(BUILD_DIR, 'popup/index.html') 130 | ); 131 | await fs.copy( 132 | resolve(SRC_DIR, 'pages/settings/index.html'), 133 | resolve(BUILD_DIR, 'pages/settings/index.html') 134 | ); 135 | await fs.copy( 136 | resolve(SRC_DIR, 'pages/desktop/index.html'), 137 | resolve(BUILD_DIR, 'pages/desktop/index.html') 138 | ); 139 | 140 | // Read and modify manifest 141 | const manifestContent = JSON.parse( 142 | await fs.readFile(resolve('public', 'manifest.json'), 'utf-8') 143 | ); 144 | 145 | // Add web_accessible_resources for fonts and other assets 146 | manifestContent.web_accessible_resources = [{ 147 | "resources": [ 148 | "assets/*", 149 | "assets/**/*", 150 | "*.js", 151 | "**/*.js", 152 | "**/*.css", 153 | "**/*.ttf", 154 | "**/*.woff", 155 | "**/*.woff2", 156 | "vs/*", 157 | "vs/**/*" 158 | ], 159 | "matches": [""] 160 | }]; 161 | 162 | await fs.writeFile( 163 | resolve(BUILD_DIR, 'manifest.json'), 164 | JSON.stringify(manifestContent, null, 2), 165 | 'utf-8' 166 | ); 167 | 168 | // Initial build 169 | const buildStart = Date.now(); 170 | await ctx.rebuild(); 171 | 172 | if (isWatch) { 173 | console.log('👀 Watching for changes...'); 174 | 175 | // Start watching with rebuild callback 176 | await ctx.watch(async (error: Error | null, result: esbuild.BuildResult | null) => { 177 | const rebuildStart = Date.now(); 178 | try { 179 | await ctx.rebuild(); 180 | const duration = Date.now() - rebuildStart; 181 | const now = new Date().toLocaleTimeString(); 182 | console.log(`🔄 [${now}] Rebuild completed successfully! (${formatDuration(duration)})`); 183 | } catch (e) { 184 | console.error('❌ Rebuild failed:', error || e); 185 | } 186 | }); 187 | } else { 188 | await ctx.dispose(); 189 | } 190 | 191 | // Add zip creation after build 192 | if (!isWatch) { 193 | await createExtensionZip(browser); 194 | } 195 | 196 | const duration = Date.now() - buildStart; 197 | console.log(`✅ Build completed successfully! (${formatDuration(duration)})`); 198 | } catch (err) { 199 | console.error('❌ Build failed:', err); 200 | process.exit(1); 201 | } 202 | } 203 | 204 | async function createExtensionZip(browser = 'chrome') { 205 | const zipName = `source-detector-${browser}.zip`; 206 | const zipPath = resolve(`dist/${zipName}`); 207 | 208 | // Remove existing zip if it exists 209 | if (await fs.pathExists(zipPath)) { 210 | await fs.remove(zipPath); 211 | } 212 | 213 | return new Promise((resolve, reject) => { 214 | const output = fs.createWriteStream(zipPath); 215 | const archive = archiver('zip', { 216 | zlib: { level: 9 } // Maximum compression 217 | }); 218 | 219 | output.on('close', () => { 220 | console.log(`Extension packaged for ${browser}: ${archive.pointer()} bytes`); 221 | resolve(); 222 | }); 223 | 224 | archive.on('warning', function (err: ArchiverError) { 225 | if (err.code === 'ENOENT') { 226 | console.warn('Warning during zip creation:', err); 227 | } else { 228 | reject(err); 229 | } 230 | }); 231 | 232 | archive.on('error', (err: ArchiverError) => { 233 | console.error('Error during zip creation:', err); 234 | reject(err); 235 | }); 236 | 237 | archive.pipe(output); 238 | 239 | // Add the dist directory contents to the zip, excluding all zip files 240 | archive.glob('**/*', { 241 | cwd: BUILD_DIR, 242 | ignore: ['*.zip'] 243 | }); 244 | 245 | archive.finalize(); 246 | }); 247 | } 248 | 249 | // Handle arguments 250 | const args = process.argv.slice(2); 251 | const isWatch = args.includes('--watch'); 252 | const browserArg = args.find(arg => arg.startsWith('--browser=')); 253 | const browser = browserArg ? browserArg.split('=')[1] : 'chrome'; 254 | 255 | // Validate browser 256 | if (browser !== 'chrome' && browser !== 'firefox') { 257 | console.error('❌ Invalid browser. Use --browser=chrome or --browser=firefox'); 258 | process.exit(1); 259 | } 260 | 261 | build(isWatch, browser); -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/background/constants.ts: -------------------------------------------------------------------------------- 1 | import { AppSettings } from '@/types'; 2 | 3 | export const MESSAGE_TYPES = { 4 | FOUND_SOURCE_MAP: 'FOUND_SOURCE_MAP', 5 | COLLECT_SOURCEMAP: 'COLLECT_SOURCEMAP', 6 | GET_SOURCEMAP: 'GET_SOURCEMAP', 7 | DELETE_SOURCEMAP: 'DELETE_SOURCEMAP', 8 | GET_STORAGE_STATS: 'GET_STORAGE_STATS', 9 | UPDATE_SETTINGS: 'UPDATE_SETTINGS', 10 | GET_SETTINGS: 'GET_SETTINGS', 11 | CLEAR_HISTORY: 'CLEAR_HISTORY', 12 | GET_PAGE_DATA: 'GET_PAGE_DATA', 13 | GET_ALL_PAGES: 'GET_ALL_PAGES', 14 | DELETE_PAGE: 'DELETE_PAGE', 15 | GET_FILE_DATA: 'GET_FILE_DATA', 16 | EXPORT_DATA: 'EXPORT_DATA', 17 | IMPORT_DATA: 'IMPORT_DATA', 18 | CLEAR_DATA: 'CLEAR_DATA', 19 | GET_ALL_SOURCE_MAPS: 'GET_ALL_SOURCE_MAPS', 20 | GET_CRX_FILE: 'GET_CRX_FILE', 21 | DOWNLOAD_CRX_FILE: 'DOWNLOAD_CRX_FILE', 22 | GET_SERVER_STATUS: 'GET_SERVER_STATUS', 23 | SERVER_STATUS_CHANGED: 'SERVER_STATUS_CHANGED' 24 | } as const; 25 | 26 | interface NumberSetting { 27 | min: number; 28 | max: number; 29 | default: number; 30 | unit?: string; 31 | } 32 | 33 | export const FILE_TYPES = { 34 | JS: 'js', 35 | CSS: 'css' 36 | } as const; 37 | 38 | export const SETTINGS = { 39 | FILE_TYPES, 40 | STORAGE: { 41 | CLEANUP_THRESHOLD: { 42 | min: 128, 43 | max: 4 * 1024 * 1024, // 4TB 44 | default: 2 * 1024, // 2GB 45 | unit: 'MB' 46 | } as NumberSetting, 47 | FILE_SIZE: { 48 | min: 1, 49 | max: 256, 50 | default: 32, 51 | unit: 'MB' 52 | } as NumberSetting, 53 | TOTAL_SIZE: { 54 | min: 64, 55 | max: 4 * 1024, // 4GB 56 | default: 1024, 57 | unit: 'MB' 58 | } as NumberSetting, 59 | FILES_COUNT: { 60 | min: 8, 61 | max: 1024, 62 | default: 1024, 63 | unit: 'files' 64 | } as NumberSetting, 65 | RETENTION_DAYS: { 66 | min: 1, 67 | max: 365, 68 | default: 30, 69 | unit: 'days' 70 | } as NumberSetting 71 | } 72 | } as const; 73 | 74 | export const STORAGE_LIMITS = SETTINGS.STORAGE; 75 | 76 | export const DEFAULT_SETTINGS: AppSettings = { 77 | id: 'settings', 78 | cleanupThreshold: 1000 79 | }; -------------------------------------------------------------------------------- /src/background/index.ts: -------------------------------------------------------------------------------- 1 | import { SourceDetectorDB } from '@/storage/database'; 2 | import { AppSettings, CrxFile, PageData } from '@/types'; 3 | import { isExtensionPage } from '@/utils/isExtensionPage'; 4 | import { parseCrxFile } from '@/utils/parseCrxFile'; 5 | import { MESSAGE_TYPES } from './constants'; 6 | import { createHash } from './utils'; 7 | import { browserAPI } from '@/utils/browser-polyfill'; 8 | 9 | const db = new SourceDetectorDB(); 10 | 11 | // Simple in-memory cache with 5s expiration 12 | const CACHE_EXPIRATION = 5000; // 5 seconds 13 | const cache = new Map(); 14 | 15 | // Function to get content from cache or fetch 16 | async function getFileContent(url: string): Promise { 17 | try { 18 | const now = Date.now(); 19 | const cached = cache.get(url); 20 | 21 | // Return cached content if it exists and hasn't expired 22 | if (cached && (now - cached.timestamp) < CACHE_EXPIRATION) { 23 | return cached.content; 24 | } 25 | 26 | // Fetch fresh content 27 | const response = await fetch(url); 28 | if (!response.ok) { 29 | throw new Error(`Failed to fetch ${url}: ${response.status}`); 30 | } 31 | const content = await response.text(); 32 | 33 | // Update cache 34 | cache.set(url, { content, timestamp: now }); 35 | 36 | // Clean old entries periodically 37 | if (cache.size > 100) { // Prevent memory leaks 38 | for (const [key, value] of cache.entries()) { 39 | if (now - value.timestamp > CACHE_EXPIRATION) { 40 | cache.delete(key); 41 | } 42 | } 43 | } 44 | 45 | return content; 46 | } catch (error) { 47 | console.error('Error getting file content:', error); 48 | throw error; 49 | } 50 | } 51 | 52 | // Store current page information 53 | let currentPage: { url: string; title: string } | null = null; 54 | 55 | // Function to update badge 56 | async function updateBadge(url: string, isCrx: boolean = false) { 57 | try { 58 | if (isCrx) { 59 | const crxFile = await db.getCrxFileByPageUrl(url); 60 | if (crxFile) { 61 | await browserAPI.action.setBadgeText({ text: String(crxFile.count) }); 62 | await browserAPI.action.setBadgeBackgroundColor({ color: '#4CAF50' }); 63 | } else { 64 | await browserAPI.action.setBadgeText({ text: '' }); 65 | } 66 | } else { 67 | // Get source maps for current page using the new schema 68 | const files = await db.getPageFiles(url); 69 | const latestFiles = files.filter(file => file.isLatest); 70 | 71 | // Update badge text and color 72 | if (latestFiles.length > 0) { 73 | await browserAPI.action.setBadgeText({ text: String(latestFiles.length) }); 74 | await browserAPI.action.setBadgeBackgroundColor({ color: '#4CAF50' }); // Green color 75 | } else { 76 | await browserAPI.action.setBadgeText({ text: '' }); 77 | } 78 | } 79 | } catch (error) { 80 | console.error('Error updating badge:', error); 81 | await browserAPI.action.setBadgeText({ text: '' }); 82 | } 83 | } 84 | 85 | // Function to update current page information 86 | async function updateCurrentPage() { 87 | try { 88 | const [tab] = await browserAPI.tabs.query({ active: true, currentWindow: true }); 89 | if (tab?.url && tab?.title && !isExtensionPage(tab.url)) { 90 | currentPage = { 91 | url: tab.url, 92 | title: tab.title 93 | }; 94 | // Update badge when page changes 95 | await updateBadge(tab.url); 96 | } 97 | } catch (error) { 98 | console.error('Error updating current page:', error); 99 | } 100 | } 101 | 102 | // Monitor tab updates 103 | browserAPI.tabs.onUpdated.addListener(async (_tabId, changeInfo, tab) => { 104 | if (changeInfo.status === 'complete' && tab.url) { 105 | console.log('tab.url', tab.url, isExtensionPage(tab.url)) 106 | if (isExtensionPage(tab.url)) { 107 | await updateCrxPage(tab); 108 | } else { 109 | await updateCurrentPage(); 110 | } 111 | } 112 | }); 113 | async function onTabActivated() { 114 | const [activeTab] = await browserAPI.tabs.query({ active: true, currentWindow: true }); 115 | if (isExtensionPage(activeTab?.url || '')) { 116 | await updateCrxPage(activeTab); 117 | } else { 118 | await updateCurrentPage(); 119 | } 120 | } 121 | 122 | // Monitor tab activation 123 | browserAPI.tabs.onActivated.addListener(onTabActivated); 124 | 125 | // Monitor window focus 126 | browserAPI.windows.onFocusChanged.addListener(onTabActivated); 127 | 128 | async function updateCrxPage(tab: browserAPI.tabs.Tab) { 129 | const url = tab.url; 130 | if (!url) return; 131 | await updateBadge(url, true); 132 | const crxUrl = await getCrxUrl(url); 133 | if (crxUrl) { 134 | // check if the file exists 135 | let crxFile = await db.getCrxFileByPageUrl(url); 136 | if (!crxFile) { 137 | // Create empty content hash for new file 138 | const emptyBlob = new Blob(); 139 | 140 | // save to db 141 | crxFile = await db.addCrxFile({ 142 | pageUrl: url, 143 | pageTitle: tab.title || '', 144 | crxUrl: crxUrl, 145 | blob: emptyBlob, 146 | size: 0, 147 | timestamp: Date.now(), 148 | count: 0, 149 | contentHash: '' 150 | }); 151 | } 152 | 153 | const result = await parseCrxFile(crxUrl); 154 | console.log('result', result); 155 | if (result && result.count > 0) { 156 | // Create content hash from blob 157 | const arrayBuffer = await result.blob.arrayBuffer(); 158 | const uint8Array = new Uint8Array(arrayBuffer); 159 | const contentHash = await createHash('SHA-256') 160 | .update(Array.from(uint8Array).map(b => String.fromCharCode(b)).join('')) 161 | .digest('hex'); 162 | 163 | await db.updateCrxFile({ 164 | id: crxFile.id, 165 | pageUrl: url, 166 | pageTitle: tab.title || '', 167 | crxUrl: crxUrl, 168 | blob: result.blob, 169 | size: result.blob.size, 170 | count: result.count, 171 | timestamp: Date.now(), 172 | contentHash, 173 | }); 174 | console.log('updateCrxFile', crxFile); 175 | // update badge 176 | await updateBadge(url, true); 177 | } 178 | } 179 | } 180 | 181 | // Modify fetchSourceMapContent to use cache 182 | async function fetchSourceMapContent(sourceUrl: string, mapUrl: string): Promise<{ 183 | content: string; 184 | originalContent: string; 185 | size: number; 186 | hash: string; 187 | } | null> { 188 | try { 189 | // Get both files using the caching function 190 | const content = await getFileContent(mapUrl); 191 | const originalContent = await getFileContent(sourceUrl); 192 | 193 | // Calculate size and hash 194 | const size = new Blob([content]).size + new Blob([originalContent]).size; 195 | const hash = await createHash('SHA-256') 196 | .update(content + originalContent) 197 | .digest('hex'); 198 | 199 | return { content, originalContent, size, hash }; 200 | } catch (error) { 201 | console.error('Error fetching source map content:', error); 202 | return null; 203 | } 204 | } 205 | 206 | // Listen for requests to detect JS/CSS files 207 | browserAPI.webRequest.onCompleted.addListener( 208 | (details) => { 209 | if (isExtensionPage(details.url)) { 210 | return; 211 | } 212 | 213 | console.log('details', details.initiator) 214 | if (!/\.(js|css)(\?.*)?$/.test(details.url)) { 215 | return; 216 | } 217 | 218 | // Process asynchronously 219 | (async () => { 220 | try { 221 | // Get content from cache or fetch 222 | const content = await getFileContent(details.url); 223 | 224 | // Check for source map in the last line 225 | // lastLine is like this: 226 | // /*# sourceMappingURL=https://example.com/path/to/map.css.map */ 227 | // or 228 | // //# sourceMappingURL=https://example.com/path/to/map.css.map 229 | const lastLine = content.split('\n').pop()?.trim() || ''; 230 | const sourceMapMatch = lastLine.match(/#\s*sourceMappingURL=([^\s\*]+)/); 231 | 232 | if (sourceMapMatch) { 233 | const mapUrl = sourceMapMatch[1]; 234 | const fullMapUrl = mapUrl.startsWith('http') 235 | ? mapUrl 236 | : new URL(mapUrl, details.url).toString(); 237 | 238 | await handleSourceMapFound({ 239 | pageTitle: currentPage?.title || '', 240 | pageUrl: currentPage?.url || '', 241 | sourceUrl: details.url, 242 | mapUrl: fullMapUrl, 243 | fileType: details.url.endsWith('.css') ? 'css' : 'js', 244 | originalContent: content 245 | }); 246 | } 247 | } catch (error) { 248 | console.error('Error processing response:', error); 249 | } 250 | })(); 251 | }, 252 | { urls: [''] } 253 | ); 254 | 255 | // 监听消息 256 | browserAPI.runtime.onMessage.addListener((message, _sender, sendResponse) => { 257 | const handleMessage = async () => { 258 | try { 259 | switch (message.type) { 260 | case MESSAGE_TYPES.FOUND_SOURCE_MAP: 261 | return await handleSourceMapFound(message.data); 262 | case MESSAGE_TYPES.GET_SOURCEMAP: 263 | return await handleGetSourceMap(message.data); 264 | case MESSAGE_TYPES.DELETE_SOURCEMAP: 265 | return await handleDeleteSourceMap(message.data); 266 | case MESSAGE_TYPES.GET_STORAGE_STATS: 267 | return await handleGetStorageStats(); 268 | case MESSAGE_TYPES.GET_SETTINGS: 269 | return await handleGetSettings(); 270 | case MESSAGE_TYPES.UPDATE_SETTINGS: 271 | return await handleUpdateSettings(message.data); 272 | case MESSAGE_TYPES.CLEAR_HISTORY: 273 | return await handleClearHistory(); 274 | case MESSAGE_TYPES.GET_FILE_DATA: 275 | return await handleGetFileData(message.data); 276 | case MESSAGE_TYPES.GET_PAGE_DATA: 277 | return await handleGetPageData(message.data); 278 | case MESSAGE_TYPES.GET_ALL_SOURCE_MAPS: 279 | return await handleGetAllSourceMaps(); 280 | case MESSAGE_TYPES.CLEAR_DATA: 281 | return await handleClearData(); 282 | case MESSAGE_TYPES.GET_CRX_FILE: 283 | return await handleGetCrxFile(message.data); 284 | case MESSAGE_TYPES.GET_SERVER_STATUS: 285 | return { success: true, data: { isOnline: serverStatus } }; 286 | default: 287 | return { success: false, reason: 'Unknown message type' }; 288 | } 289 | } catch (error) { 290 | console.error('Error handling message:', error); 291 | return { success: false, reason: String(error) }; 292 | } 293 | }; 294 | 295 | handleMessage().then(sendResponse); 296 | return true; 297 | }); 298 | 299 | async function handleGetCrxFile(data: { url: string }) { 300 | try { 301 | const crxFile = await db.getCrxFileByPageUrl(data.url); 302 | if (!crxFile) return { success: false, reason: 'CRX file not found' }; 303 | 304 | return { success: true, data: crxFile }; 305 | } catch (error) { 306 | console.error('Error getting CRX file:', error); 307 | return { success: false, reason: String(error) }; 308 | } 309 | } 310 | 311 | async function handleClearData() { 312 | try { 313 | await db.sourceMapFiles.clear(); 314 | return { success: true }; 315 | } catch (error) { 316 | console.error('Error clearing data:', error); 317 | return { success: false, reason: String(error) }; 318 | } 319 | } 320 | 321 | async function handleGetPageData(data: { url: string }) { 322 | try { 323 | const files = await db.getPageFiles(data.url); 324 | const pageData: PageData = { 325 | url: data.url, 326 | title: currentPage?.title || '', 327 | timestamp: Date.now(), 328 | files: files 329 | }; 330 | return { success: true, data: pageData }; 331 | } catch (error) { 332 | console.error('Error getting page data:', error); 333 | return { success: false, reason: String(error) }; 334 | } 335 | } 336 | 337 | async function handleGetFileData(data: { url: string }) { 338 | try { 339 | const file = await db.sourceMapFiles.where('url').equals(data.url).first(); 340 | return { success: true, data: file }; 341 | } catch (error) { 342 | console.error('Error getting file data:', error); 343 | return { success: false, reason: String(error) }; 344 | } 345 | } 346 | 347 | // Task queue implementation 348 | class TaskQueue { 349 | private queue: Map> = new Map(); 350 | 351 | async enqueue(key: string, task: () => Promise): Promise { 352 | const currentTask = this.queue.get(key) || Promise.resolve(); 353 | try { 354 | // Create a new task that waits for the current task to complete 355 | const newTask = currentTask.then(task, task); 356 | 357 | // Update the queue with the new task 358 | this.queue.set(key, newTask); 359 | 360 | // Wait for the task to complete and return its result 361 | const result = await newTask; 362 | 363 | // Clean up if this was the last task in the queue 364 | if (this.queue.get(key) === newTask) { 365 | this.queue.delete(key); 366 | } 367 | 368 | return result; 369 | } catch (error) { 370 | // Clean up on error 371 | if (this.queue.get(key) === currentTask) { 372 | this.queue.delete(key); 373 | } 374 | throw error; 375 | } 376 | } 377 | } 378 | 379 | const taskQueue = new TaskQueue(); 380 | 381 | // Function to handle source map found 382 | async function handleSourceMapFound(data: { pageTitle: string; pageUrl: string; sourceUrl: string; mapUrl: string; fileType: 'js' | 'css'; originalContent: string }): Promise<{ success: boolean; reason?: string }> { 383 | return taskQueue.enqueue('sourceMap', async () => { 384 | try { 385 | // Fetch content 386 | const content = await fetchSourceMapContent(data.sourceUrl, data.mapUrl); 387 | if (!content) return { success: false, reason: 'Failed to fetch content' }; 388 | 389 | // Check existing file 390 | const existingFile = await db.sourceMapFiles.where('url').equals(data.sourceUrl).first(); 391 | 392 | // Check if content unchanged 393 | if (existingFile && existingFile.hash === content.hash) { 394 | // Even if content is unchanged, we still need to associate it with the current page 395 | await db.addSourceMapToPage(data.pageUrl, data.pageTitle, existingFile); 396 | return { success: true, reason: 'File content unchanged but added to page' }; 397 | } 398 | 399 | // Update existing versions if they exist 400 | if (existingFile) { 401 | const existingFiles = await db.sourceMapFiles 402 | .where('url') 403 | .equals(data.sourceUrl) 404 | .toArray(); 405 | await Promise.all( 406 | existingFiles.map(file => 407 | db.sourceMapFiles.update(file.id, { isLatest: false }) 408 | ) 409 | ); 410 | } 411 | 412 | // Get latest version number 413 | const latestVersion = existingFile ? 414 | (await db.sourceMapFiles 415 | .where('url') 416 | .equals(data.sourceUrl) 417 | .toArray()) 418 | .reduce((max, file) => Math.max(max, file.version), 0) : 0; 419 | 420 | // Create new source map record 421 | const sourceMapFile = { 422 | url: data.sourceUrl, 423 | sourceMapUrl: data.mapUrl, 424 | content: content.content, 425 | originalContent: content.originalContent, 426 | fileType: data.fileType, 427 | size: content.size, 428 | timestamp: Date.now(), 429 | version: latestVersion + 1, 430 | hash: content.hash, 431 | isLatest: true, 432 | }; 433 | 434 | // Store source map 435 | const savedSourceMapFile = await db.addSourceMapFile(sourceMapFile); 436 | 437 | // Associate with page 438 | await db.addSourceMapToPage(data.pageUrl, data.pageTitle, savedSourceMapFile); 439 | 440 | checkAndCleanStorage(); 441 | // Update badge after storing new source map 442 | await updateBadge(data.pageUrl); 443 | 444 | return { success: true }; 445 | } catch (error) { 446 | console.error('Error handling source map:', error); 447 | return { success: false, reason: String(error) }; 448 | } 449 | }); 450 | } 451 | 452 | // 获取 sourcemap 453 | async function handleGetSourceMap(data: { url: string }) { 454 | try { 455 | const file = await db.sourceMapFiles 456 | .where('url') 457 | .equals(data.url) 458 | .first(); 459 | 460 | return { success: true, data: file }; 461 | } catch (error) { 462 | console.error('Error getting sourcemap:', error); 463 | return { success: false, reason: String(error) }; 464 | } 465 | } 466 | 467 | // 删除 sourcemap 468 | async function handleDeleteSourceMap(data: { url: string }) { 469 | try { 470 | await db.sourceMapFiles 471 | .where('url') 472 | .equals(data.url) 473 | .delete(); 474 | 475 | return { success: true }; 476 | } catch (error) { 477 | console.error('Error deleting sourcemap:', error); 478 | return { success: false, reason: String(error) }; 479 | } 480 | } 481 | 482 | // 获取存储统计信息 483 | async function handleGetStorageStats() { 484 | try { 485 | const stats = await db.getStorageStats(); 486 | return { success: true, data: stats }; 487 | } catch (error) { 488 | console.error('Error getting storage stats:', error); 489 | return { success: false, reason: String(error) }; 490 | } 491 | } 492 | 493 | // 获取设置 494 | async function handleGetSettings() { 495 | try { 496 | const settings = await db.getSettings(); 497 | return { success: true, data: settings }; 498 | } catch (error) { 499 | console.error('Error getting settings:', error); 500 | return { success: false, reason: String(error) }; 501 | } 502 | } 503 | 504 | // 更新设置 505 | async function handleUpdateSettings(settings: Partial) { 506 | try { 507 | await db.updateSettings(settings); 508 | return { success: true }; 509 | } catch (error) { 510 | console.error('Error updating settings:', error); 511 | return { success: false, reason: String(error) }; 512 | } 513 | } 514 | 515 | // 清空史记 516 | async function handleClearHistory() { 517 | try { 518 | await Promise.all([ 519 | db.sourceMapFiles.clear(), 520 | db.pages.clear(), 521 | db.pageSourceMaps.clear() 522 | ]); 523 | return { success: true }; 524 | } catch (error) { 525 | console.error('Error clearing history:', error); 526 | return { success: false, reason: String(error) }; 527 | } 528 | } 529 | 530 | // 检查并清理存储 531 | async function checkAndCleanStorage() { 532 | try { 533 | const settings = await db.getSettings(); 534 | const stats = await db.getStorageStats(); 535 | if (stats.totalSize > settings.cleanupThreshold * 1024 * 1024) { 536 | // Delete oldest files first 537 | await db.sourceMapFiles 538 | .orderBy('timestamp') 539 | .limit(100) 540 | .delete(); 541 | } 542 | } catch (error) { 543 | console.error('Error cleaning storage:', error); 544 | } 545 | } 546 | 547 | async function handleGetAllSourceMaps() { 548 | try { 549 | const files = await db.sourceMapFiles.toArray(); 550 | return { success: true, data: files }; 551 | } catch (error) { 552 | console.error('Error getting all source maps:', error); 553 | return { success: false, reason: String(error) }; 554 | } 555 | } 556 | 557 | // Get CRX download URL from extension page 558 | async function getCrxUrl(url: string): Promise { 559 | try { 560 | // Extract extension ID from various URL patterns 561 | const cws_pattern = /^https?:\/\/(?:chrome.google.com\/webstore|chromewebstore.google.com)\/.+?\/([a-z]{32})(?=[\/#?]|$)/; 562 | const match = cws_pattern.exec(url); 563 | const extId = match?.[1] || url.split('/')[6]?.split('?')[0] || url.split('//')[1]?.split('/')[0]; 564 | if (!extId || !/^[a-z]{32}$/.test(extId)) return null; 565 | 566 | const platformInfo = await browserAPI.runtime.getPlatformInfo(); 567 | const version = navigator.userAgent.split("Chrome/")[1]?.split(" ")[0] || '9999.0.9999.0'; 568 | 569 | // Construct URL with all necessary parameters 570 | let downloadUrl = 'https://clients2.google.com/service/update2/crx?response=redirect'; 571 | downloadUrl += '&os=' + platformInfo.os; 572 | downloadUrl += '&arch=' + platformInfo.arch; 573 | downloadUrl += '&os_arch=' + platformInfo.arch; 574 | downloadUrl += '&nacl_arch=' + platformInfo.nacl_arch; 575 | // Use chromiumcrx as product ID since we're not Chrome 576 | downloadUrl += '&prod=chromiumcrx'; 577 | downloadUrl += '&prodchannel=unknown'; 578 | downloadUrl += '&prodversion=' + version; 579 | downloadUrl += '&acceptformat=crx2,crx3'; 580 | downloadUrl += '&x=id%3D' + extId; 581 | downloadUrl += '%26uc'; 582 | 583 | return downloadUrl; 584 | } catch (error) { 585 | console.error('Error getting CRX URL:', error); 586 | return null; 587 | } 588 | } 589 | 590 | // Server configuration 591 | const SERVER_CONFIG = { 592 | host: '127.0.0.1', 593 | port: '63798' 594 | }; 595 | 596 | const SERVER_URL = `http://${SERVER_CONFIG.host}:${SERVER_CONFIG.port}`; 597 | const HEARTBEAT_INTERVAL = 5000; // 5 seconds 598 | const SYNC_CHECK_INTERVAL = 60 * 1000; // 1 minute 599 | 600 | // Tables to sync 601 | const TableChunkSizeMap = { 602 | crxFiles: 1, 603 | sourceMapFiles: 1, 604 | pages: 100, 605 | pageSourceMaps: 100 606 | } as const; 607 | 608 | let serverStatus = false; 609 | 610 | // Function to check server health 611 | async function checkServerHealth(): Promise { 612 | try { 613 | const response = await fetch(`${SERVER_URL}/health`, { 614 | method: 'GET', 615 | headers: { 616 | 'Accept': 'application/json', 617 | } 618 | }); 619 | const { status } = await response.json(); 620 | return response.ok && status === 'ok'; 621 | } catch (error) { 622 | console.error('Error checking server health:', error); 623 | return false; 624 | } 625 | } 626 | 627 | // Heartbeat function 628 | async function checkServerStatus() { 629 | serverStatus = await checkServerHealth(); 630 | 631 | // Broadcast status to all tabs 632 | browserAPI.runtime.sendMessage({ 633 | type: MESSAGE_TYPES.SERVER_STATUS_CHANGED, 634 | data: { isOnline: serverStatus } 635 | }).catch(() => { }); // Ignore errors if no listeners 636 | } 637 | 638 | // Function to convert Blob to base64 639 | async function blobToBase64(blob: Blob): Promise { 640 | const arrayBuffer = await blob.arrayBuffer(); 641 | const uint8Array = new Uint8Array(arrayBuffer); 642 | 643 | // Process the array in chunks to avoid call stack size exceeded 644 | const chunkSize = 0x8000; // 32KB chunks 645 | let result = ''; 646 | 647 | for (let i = 0; i < uint8Array.length; i += chunkSize) { 648 | const chunk = uint8Array.slice(i, i + chunkSize); 649 | result += String.fromCharCode.apply(null, Array.from(chunk)); 650 | } 651 | 652 | return btoa(result); 653 | } 654 | 655 | // Function to sync data to server 656 | async function syncDataToServer() { 657 | try { 658 | for (const table of Object.keys(TableChunkSizeMap)) { 659 | console.log('syncDataToServer', table); 660 | let lastId = await db.getLastSyncId(table); 661 | console.log('lastId', lastId); 662 | let modifiedData = await db.getModifiedData(table, lastId, TableChunkSizeMap[table as keyof typeof TableChunkSizeMap]); 663 | console.log('modifiedData', modifiedData); 664 | 665 | while (modifiedData.length > 0) { 666 | try { 667 | let processedChunk = table === 'crxFiles' 668 | ? await Promise.all(modifiedData.map(async (file) => { 669 | const crxFile = file as CrxFile; 670 | return { 671 | ...crxFile, 672 | blob: await blobToBase64(crxFile.blob) 673 | }; 674 | })) 675 | : modifiedData; 676 | 677 | const response = await fetch(`${SERVER_URL}/sync`, { 678 | method: 'POST', 679 | headers: { 'Content-Type': 'application/json' }, 680 | body: JSON.stringify({ 681 | table, 682 | lastId, 683 | data: processedChunk 684 | }) 685 | }); 686 | 687 | if (response.ok) { 688 | const result = await response.json(); 689 | 690 | // Log failed records for debugging 691 | if (result.failedRecords?.length > 0) { 692 | console.log(`Failed records for ${table}:`, result.failedRecords); 693 | } 694 | 695 | // Update last synced ID to the last successful record's ID 696 | if (result.lastSuccessId > lastId) { 697 | lastId = result.lastSuccessId; 698 | await db.updateLastSyncId(table, lastId); 699 | } 700 | 701 | console.log(`Synced ${modifiedData.length} records for ${table}`); 702 | } else { 703 | console.log(`Failed to sync chunk: ${response.statusText}`); 704 | break; 705 | } 706 | } catch (error) { 707 | console.log('error', error); 708 | } 709 | 710 | modifiedData = await db.getModifiedData(table, lastId, TableChunkSizeMap[table as keyof typeof TableChunkSizeMap]); 711 | console.log('modifiedData2', modifiedData); 712 | } 713 | } 714 | 715 | console.log('Data sync completed successfully'); 716 | } catch (error) { 717 | console.error('Error syncing data:', error); 718 | } 719 | } 720 | 721 | let inSync = false; 722 | // Function to check server status and trigger sync 723 | async function checkServerAndSync() { 724 | if (inSync) { 725 | return; 726 | } 727 | inSync = true; 728 | console.log('checkServerAndSync'); 729 | if (await checkServerHealth()) { 730 | console.log('checkServerAndSync2'); 731 | await syncDataToServer(); 732 | } 733 | inSync = false; 734 | } 735 | 736 | // Start heartbeat and sync 737 | setInterval(checkServerStatus, HEARTBEAT_INTERVAL); 738 | setInterval(checkServerAndSync, SYNC_CHECK_INTERVAL); 739 | 740 | // Initial checks 741 | checkServerStatus(); 742 | checkServerAndSync(); -------------------------------------------------------------------------------- /src/background/utils.ts: -------------------------------------------------------------------------------- 1 | import { SourceMapFile } from '@/types'; 2 | import { formatBytes } from '@/utils/format'; 3 | import JSZip from 'jszip'; 4 | 5 | 6 | /** 7 | * Create a download URL for a file 8 | */ 9 | export function createDownloadUrl(file: SourceMapFile): string { 10 | const blob = new Blob([file.content], { type: 'application/json' }); 11 | return URL.createObjectURL(blob); 12 | } 13 | 14 | /** 15 | * Clean up a download URL 16 | */ 17 | export function revokeDownloadUrl(url: string): void { 18 | URL.revokeObjectURL(url); 19 | } 20 | 21 | /** 22 | * Format a timestamp to a readable date string 23 | */ 24 | export function formatDate(timestamp: number): string { 25 | return new Date(timestamp).toLocaleString(); 26 | } 27 | 28 | /** 29 | * Create a ZIP file containing sourcemaps 30 | */ 31 | export async function createZipFile(files: SourceMapFile[]): Promise { 32 | const zip = new JSZip(); 33 | 34 | // Group files by type 35 | const jsFiles = files.filter(f => f.fileType === 'js'); 36 | const cssFiles = files.filter(f => f.fileType === 'css'); 37 | 38 | // Add JS files 39 | if (jsFiles.length > 0) { 40 | const jsFolder = zip.folder('js'); 41 | jsFiles.forEach(file => { 42 | const fileName = new URL(file.url).pathname.split('/').pop() || 'unknown.js.map'; 43 | jsFolder?.file(fileName, file.content); 44 | }); 45 | } 46 | 47 | // Add CSS files 48 | if (cssFiles.length > 0) { 49 | const cssFolder = zip.folder('css'); 50 | cssFiles.forEach(file => { 51 | const fileName = new URL(file.url).pathname.split('/').pop() || 'unknown.css.map'; 52 | cssFolder?.file(fileName, file.content); 53 | }); 54 | } 55 | 56 | // Generate metadata.json 57 | const metadata = { 58 | timestamp: Date.now(), 59 | fileCount: files.length, 60 | files: files.map(f => ({ 61 | url: f.url, 62 | sourceMapUrl: f.sourceMapUrl, 63 | fileType: f.fileType, 64 | size: f.size 65 | })) 66 | }; 67 | zip.file('metadata.json', JSON.stringify(metadata, null, 2)); 68 | 69 | return await zip.generateAsync({ type: 'blob' }); 70 | } 71 | 72 | /** 73 | * Get icon for file type 74 | */ 75 | export function getFileTypeIcon(fileType: 'js' | 'css'): string { 76 | return fileType === 'js' ? '📄' : '🎨'; 77 | } 78 | 79 | /** 80 | * Extract filename from URL 81 | */ 82 | export function getFileNameFromUrl(url: string): string { 83 | try { 84 | const urlObj = new URL(url); 85 | const fileName = urlObj.pathname.split('/').pop(); 86 | return fileName || 'unknown'; 87 | } catch { 88 | return 'unknown'; 89 | } 90 | } 91 | 92 | /** 93 | * Create a hash for a given string 94 | */ 95 | export function createHash(algorithm: string) { 96 | const encoder = new TextEncoder(); 97 | 98 | return { 99 | update(data: string) { 100 | const buffer = encoder.encode(data); 101 | return { 102 | async digest(_encoding: string) { 103 | const hashBuffer = await crypto.subtle.digest(algorithm.toUpperCase(), buffer); 104 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 105 | return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 106 | } 107 | }; 108 | } 109 | }; 110 | } 111 | 112 | export function getFileExtension(filename: string): string { 113 | const ext = filename.split('.').pop()?.toLowerCase() || ''; 114 | return ext; 115 | } 116 | 117 | export function isJavaScriptFile(filename: string): boolean { 118 | const ext = getFileExtension(filename); 119 | return ext === 'js' || ext === 'mjs' || ext === 'cjs'; 120 | } 121 | 122 | export function isCSSFile(filename: string): boolean { 123 | const ext = getFileExtension(filename); 124 | return ext === 'css'; 125 | } 126 | 127 | export function isSourceMapFile(filename: string): boolean { 128 | const ext = getFileExtension(filename); 129 | return ext === 'map'; 130 | } 131 | 132 | export function isChromePage(url: string): boolean { 133 | return url.startsWith('chrome://') || url.startsWith('chrome-extension://'); 134 | } 135 | 136 | export function isChromeWebStorePage(url: string): boolean { 137 | return url.startsWith('https://chromewebstore.google.com/'); 138 | } 139 | 140 | export function isExtensionPage(url: string): boolean { 141 | return isChromePage(url) || isChromeWebStorePage(url); 142 | } 143 | 144 | export { formatBytes }; 145 | -------------------------------------------------------------------------------- /src/components/Toast.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Snackbar, Box, Typography } from '@mui/material'; 3 | import { 4 | CheckCircle as SuccessIcon, 5 | Error as ErrorIcon, 6 | Info as InfoIcon, 7 | Warning as WarningIcon 8 | } from '@mui/icons-material'; 9 | 10 | type ToastSeverity = 'success' | 'error' | 'info' | 'warning'; 11 | 12 | interface ToastProps { 13 | open: boolean; 14 | message: string; 15 | severity?: ToastSeverity; 16 | onClose: () => void; 17 | autoHideDuration?: number; 18 | } 19 | 20 | const severityColors: Record = { 21 | success: '#4caf50', 22 | error: '#f44336', 23 | info: '#2196f3', 24 | warning: '#ff9800' 25 | }; 26 | 27 | const severityIcons: Record = { 28 | success: , 29 | error: , 30 | info: , 31 | warning: 32 | }; 33 | 34 | export function Toast({ 35 | open, 36 | message, 37 | severity = 'info', 38 | onClose, 39 | autoHideDuration = 2000 40 | }: ToastProps) { 41 | return ( 42 | 49 | 64 | {severityIcons[severity]} 65 | 66 | {message} 67 | 68 | 69 | 70 | ); 71 | } -------------------------------------------------------------------------------- /src/components/fileIcon.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Code as CodeIcon, 3 | Css as CssIcon, 4 | Html as HtmlIcon, 5 | Image as ImageIcon, 6 | InsertDriveFile, 7 | Javascript as JavascriptIcon, 8 | DataObject as JsonIcon, 9 | Description as TextIcon 10 | } from '@mui/icons-material'; 11 | 12 | export const getFileIcon = (fileName: string) => { 13 | const extension = fileName.split('.').pop()?.toLowerCase(); 14 | switch (extension) { 15 | case 'js': 16 | case 'jsx': 17 | case 'ts': 18 | case 'tsx': 19 | return ; 20 | case 'css': 21 | case 'scss': 22 | case 'sass': 23 | case 'less': 24 | return ; 25 | case 'json': 26 | return ; 27 | case 'html': 28 | case 'htm': 29 | return ; 30 | case 'png': 31 | case 'jpg': 32 | case 'jpeg': 33 | case 'gif': 34 | case 'svg': 35 | case 'webp': 36 | case 'ico': 37 | return ; 38 | case 'xml': 39 | case 'yaml': 40 | case 'yml': 41 | return ; 42 | case 'txt': 43 | case 'md': 44 | return ; 45 | default: 46 | return ; 47 | } 48 | }; -------------------------------------------------------------------------------- /src/pages/desktop/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | AppBar, 4 | Toolbar, 5 | Typography, 6 | Button, 7 | Container, 8 | Grid, 9 | Card, 10 | CardContent, 11 | CardHeader, 12 | Box, 13 | Link, 14 | ThemeProvider, 15 | createTheme, 16 | CssBaseline 17 | } from '@mui/material'; 18 | import { styled } from '@mui/system'; 19 | import DownloadIcon from '@mui/icons-material/Download'; 20 | 21 | // Create a custom theme 22 | const theme = createTheme({ 23 | palette: { 24 | primary: { 25 | main: '#3f51b5', 26 | }, 27 | secondary: { 28 | main: '#f50057', 29 | }, 30 | }, 31 | }); 32 | 33 | // Styled components 34 | const HeroSection = styled('section')(({ theme }) => ({ 35 | paddingTop: theme.spacing(12), 36 | paddingBottom: theme.spacing(6), 37 | textAlign: 'center', 38 | })); 39 | 40 | const FeatureCard = styled(Card)(({ theme }) => ({ 41 | height: '100%', 42 | display: 'flex', 43 | flexDirection: 'column', 44 | })); 45 | 46 | const DownloadSection = styled('section')(({ theme }) => ({ 47 | padding: theme.spacing(8, 0), 48 | backgroundColor: theme.palette.grey[100], 49 | })); 50 | 51 | export default function LandingPage() { 52 | return ( 53 | 54 | 55 | 56 | {/* Header */} 57 | 58 | 59 | 60 | Source Detector 61 | 62 | 63 | 64 | 65 | 66 | 67 | {/* Hero Section */} 68 | 69 | 70 | 71 | Source Map & CRX File Detection Made Easy 72 | 73 | 74 | Powerful desktop application for detecting and managing source maps and Chrome extension files. Seamlessly sync with your browser extension. 75 | 76 | 77 | 80 | 83 | 84 | 85 | 86 | 87 | {/* Features Section */} 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | Automatically detect source maps and Chrome extension (CRX) files while browsing. Our intelligent system identifies and catalogs these files for easy access. 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | Keep your source maps and CRX files neatly organized by website, making it simple to find and manage your collected files. 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | View and analyze the contents of your detected files with our built-in code viewer. Syntax highlighting and file structure navigation make code exploration effortless. 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | Download any detected source maps or CRX files with a single click. Perfect for developers who need quick access to these resources for analysis or debugging. 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | {/* Download Section */} 134 | 135 | 136 | 137 | 138 | Ready to Get Started? 139 | 140 | 141 | Download Source Detector now and take control of your source maps and Chrome extensions. 142 | 143 | 151 | 152 | 153 | 154 | 155 | {/* Footer */} 156 | 157 | 158 | 159 | 160 | 161 | © 2024 Source Detector. All rights reserved. 162 | 163 | 164 | 165 | Privacy 166 | Terms 167 | Contact 168 | 169 | 170 | 171 | 172 | 173 | 174 | ); 175 | } 176 | 177 | -------------------------------------------------------------------------------- /src/pages/desktop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Source Detector Desktop 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/pages/desktop/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | createRoot(document.getElementById('root')!).render( 6 | 7 | 8 | 9 | ); -------------------------------------------------------------------------------- /src/pages/settings/App.tsx: -------------------------------------------------------------------------------- 1 | import { DEFAULT_SETTINGS, MESSAGE_TYPES, STORAGE_LIMITS } from '@/background/constants'; 2 | import { formatBytes } from '@/background/utils'; 3 | import { Toast } from '@/components/Toast'; 4 | import { AppSettings, StorageStats } from '@/types'; 5 | import { Delete as DeleteIcon } from '@mui/icons-material'; 6 | import { 7 | Alert, 8 | AppBar, 9 | Box, 10 | Button, 11 | CircularProgress, 12 | Dialog, 13 | DialogActions, 14 | DialogContent, 15 | DialogTitle, 16 | List, 17 | ListItem, 18 | ListItemSecondaryAction, 19 | ListItemText, 20 | Slider, 21 | Toolbar, 22 | Typography 23 | } from '@mui/material'; 24 | import { createTheme, ThemeProvider } from '@mui/material/styles'; 25 | import React, { useEffect, useState } from 'react'; 26 | import { browserAPI } from '@/utils/browser-polyfill'; 27 | 28 | export default function App() { 29 | const [loading, setLoading] = useState(true); 30 | const [settings, setSettings] = useState(null); 31 | const [stats, setStats] = useState(null); 32 | const [clearDialogOpen, setClearDialogOpen] = useState(false); 33 | const [message, setMessage] = useState<{ type: 'success' | 'error' | 'info' | 'warning'; text: string } | null>(null); 34 | 35 | useEffect(() => { 36 | loadData(); 37 | }, []); 38 | 39 | const loadData = async () => { 40 | try { 41 | const response = await browserAPI.runtime.sendMessage({ 42 | type: MESSAGE_TYPES.GET_SETTINGS 43 | }); 44 | 45 | if (response.success) { 46 | setSettings(response.data); 47 | } 48 | 49 | const statsResponse = await browserAPI.runtime.sendMessage({ 50 | type: MESSAGE_TYPES.GET_STORAGE_STATS 51 | }); 52 | 53 | if (statsResponse.success) { 54 | setStats(statsResponse.data); 55 | } 56 | } catch (error) { 57 | console.error('Error loading settings:', error); 58 | } finally { 59 | setLoading(false); 60 | } 61 | }; 62 | 63 | const handleSettingChange = async (key: keyof AppSettings, value: any) => { 64 | if (!settings) return; 65 | 66 | try { 67 | const newSettings = { ...settings, [key]: value }; 68 | const response = await browserAPI.runtime.sendMessage({ 69 | type: MESSAGE_TYPES.UPDATE_SETTINGS, 70 | data: newSettings 71 | }); 72 | 73 | if (response.success) { 74 | setSettings(newSettings); 75 | setMessage({ type: 'success', text: 'Settings saved successfully' }); 76 | } 77 | } catch (error) { 78 | console.error('Error updating settings:', error); 79 | setMessage({ type: 'error', text: 'Failed to save settings' }); 80 | } 81 | }; 82 | 83 | const handleClearData = async () => { 84 | try { 85 | setLoading(true); 86 | const response = await browserAPI.runtime.sendMessage({ 87 | type: MESSAGE_TYPES.CLEAR_DATA 88 | }); 89 | 90 | if (response.success) { 91 | setStats({ 92 | usedSpace: 0, 93 | fileCount: 0, 94 | totalSize: 0, 95 | pagesCount: 0, 96 | oldestTimestamp: Date.now(), 97 | uniqueSiteCount: 0 98 | }); 99 | setSettings(DEFAULT_SETTINGS); 100 | setMessage({ type: 'success', text: 'Data cleared successfully' }); 101 | } else { 102 | throw new Error(response.error || 'Failed to clear data'); 103 | } 104 | } catch (error) { 105 | console.error('Error clearing data:', error); 106 | setMessage({ type: 'error', text: 'Failed to clear data' }); 107 | } finally { 108 | setClearDialogOpen(false); 109 | setLoading(false); 110 | } 111 | }; 112 | 113 | // Create theme based on dark mode setting 114 | const theme = React.useMemo( 115 | () => 116 | createTheme({ 117 | palette: { 118 | mode: 'light', 119 | }, 120 | }), 121 | [] 122 | ); 123 | 124 | if (loading) { 125 | return ( 126 | 127 | 128 | 129 | ); 130 | } 131 | 132 | return ( 133 | 134 | 143 | 144 | 145 | 146 | Source Detector - Settings 147 | 148 | 149 | 150 | 151 | 152 | setMessage(null)} 157 | /> 158 | 159 | {stats && ( 160 | 161 | Storage used: {formatBytes(stats.usedSpace)} • {stats.fileCount} Source Maps Found on {stats.uniqueSiteCount} {stats.uniqueSiteCount === 1 ? 'Site' : 'Sites'} 162 | 163 | )} 164 | 165 | 166 | 167 | 173 | 174 | handleSettingChange('cleanupThreshold', value)} 180 | /> 181 | 182 | 183 | 184 | 185 | 186 | 194 | 195 | 196 | 197 | setClearDialogOpen(false)} 200 | > 201 | Clear Data 202 | 203 | 204 | Are you sure you want to delete all data? 205 | This action cannot be undone. 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | ); 217 | } -------------------------------------------------------------------------------- /src/pages/settings/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Source Detector Settings 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/pages/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { ThemeProvider, CssBaseline } from '@mui/material'; 4 | import { useAppTheme } from '@/theme'; 5 | import App from './App'; 6 | 7 | function Root() { 8 | const theme = useAppTheme(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | createRoot(document.getElementById('root')!).render( 19 | 20 | 21 | 22 | ); -------------------------------------------------------------------------------- /src/popup/App.tsx: -------------------------------------------------------------------------------- 1 | import { MESSAGE_TYPES } from '@/background/constants'; 2 | import { formatBytes } from '@/background/utils'; 3 | import { Toast } from '@/components/Toast'; 4 | import { CrxFile, PageData, ParsedCrxFile, SourceMapFile, StorageStats } from '@/types'; 5 | import { isExtensionPage } from '@/utils/isExtensionPage'; 6 | import { parseCrxFile } from '@/utils/parseCrxFile'; 7 | import { SourceMapDownloader } from '@/utils/sourceMapDownloader'; 8 | import { groupSourceMapFiles } from '@/utils/sourceMapUtils'; 9 | import { 10 | CloudDownload as CloudDownloadIcon, 11 | ListAlt as ListAltIcon, 12 | Settings as SettingsIcon, 13 | CircleOutlined, 14 | CheckCircle 15 | } from '@mui/icons-material'; 16 | import { 17 | Box, 18 | Button, 19 | CircularProgress, 20 | IconButton, 21 | Tooltip, 22 | Typography 23 | } from '@mui/material'; 24 | import JSZip from 'jszip'; 25 | import { useEffect, useMemo, useState } from 'react'; 26 | import { CrxFileTree } from './components/CrxFileTree'; 27 | import { SourceMapTable } from './components/SourceMapTable'; 28 | import { openInDesktop } from '@/utils/desktopApp'; 29 | import { browserAPI } from '@/utils/browser-polyfill'; 30 | 31 | // Helper function to format bundle size 32 | function getBundleSize(files: SourceMapFile[]): string { 33 | const totalSize = files.reduce((sum, file) => sum + file.size, 0); 34 | return formatBytes(totalSize); 35 | } 36 | 37 | export default function App() { 38 | const [loading, setLoading] = useState(true); 39 | const [pageData, setPageData] = useState(null); 40 | const [crxFile, setCrxFile] = useState(null); 41 | const [parsed, setParsed] = useState(null); 42 | const [stats, setStats] = useState(null); 43 | const [downloading, setDownloading] = useState<{ [key: string]: boolean }>({}); 44 | const [downloadingAll, setDownloadingAll] = useState(false); 45 | const [toast, setToast] = useState<{ 46 | open: boolean; 47 | message: string; 48 | severity: 'success' | 'error' | 'info' | 'warning'; 49 | }>({ 50 | open: false, 51 | message: '', 52 | severity: 'info' 53 | }); 54 | const [serverStatus, setServerStatus] = useState(false); 55 | 56 | useEffect(() => { 57 | loadData(); 58 | }, []); 59 | 60 | useEffect(() => { 61 | // Check initial server status 62 | browserAPI.runtime.sendMessage({ 63 | type: MESSAGE_TYPES.GET_SERVER_STATUS 64 | }).then(response => { 65 | if (response.success) { 66 | setServerStatus(response.data.isOnline); 67 | } 68 | }); 69 | 70 | // Listen for server status changes 71 | const listener = (message: any) => { 72 | if (message.type === MESSAGE_TYPES.SERVER_STATUS_CHANGED) { 73 | setServerStatus(message.data.isOnline); 74 | } 75 | }; 76 | browserAPI.runtime.onMessage.addListener(listener); 77 | return () => browserAPI.runtime.onMessage.removeListener(listener); 78 | }, []); 79 | 80 | const loadData = async () => { 81 | try { 82 | console.log('loadData') 83 | const [tab] = await browserAPI.tabs.query({ active: true, currentWindow: true }); 84 | console.log('tab.url', tab.url) 85 | if (!tab.url) return; 86 | if (isExtensionPage(tab.url)) { 87 | console.log('isExtensionPage', tab.url) 88 | const response = await browserAPI.runtime.sendMessage({ 89 | type: MESSAGE_TYPES.GET_CRX_FILE, 90 | data: { url: tab.url } 91 | }); 92 | console.log('response', response); 93 | if (response.success && response.data) { 94 | setCrxFile(response.data); 95 | const result = await parseCrxFile(response.data.crxUrl); 96 | setParsed(result); 97 | } 98 | } else { 99 | console.log('is not extension page', tab.url) 100 | const response = await browserAPI.runtime.sendMessage({ 101 | type: MESSAGE_TYPES.GET_PAGE_DATA, 102 | data: { url: tab.url } 103 | }); 104 | console.log('response', response) 105 | setPageData(response.data); 106 | } 107 | const statsResponse = await browserAPI.runtime.sendMessage({ 108 | type: MESSAGE_TYPES.GET_STORAGE_STATS 109 | }); 110 | console.log('statsResponse', statsResponse) 111 | setStats(statsResponse.data); 112 | } catch (error) { 113 | console.error('Error loading data:', error); 114 | } finally { 115 | setLoading(false); 116 | } 117 | }; 118 | 119 | const handleViewAllPages = () => { 120 | openInDesktop('handleViewAllPages', serverStatus, {}); 121 | }; 122 | 123 | const handleDownload = async (file: SourceMapFile) => { 124 | try { 125 | setDownloading(prev => ({ ...prev, [file.id]: true })); 126 | await SourceMapDownloader.downloadSingle(file, { 127 | onError: (error) => { 128 | showToast(error.message, 'error'); 129 | } 130 | }); 131 | showToast('Download completed successfully', 'success'); 132 | } catch (error) { 133 | showToast('Failed to download file', 'error'); 134 | } finally { 135 | setDownloading(prev => ({ ...prev, [file.id]: false })); 136 | } 137 | }; 138 | 139 | const handleVersionMenuOpen = (groupUrl: string) => { 140 | openInDesktop('handleVersionMenuOpen', serverStatus, { groupUrl }); 141 | }; 142 | 143 | const handleDownloadAll = async () => { 144 | if (crxFile && parsed) { 145 | try { 146 | setDownloadingAll(true); 147 | // Create a new zip file 148 | const newZip = new JSZip(); 149 | 150 | // Add the original CRX file directly 151 | newZip.file('extension.crx', parsed.blob); 152 | 153 | // Create a folder for parsed files 154 | const parsedFolder = newZip.folder('parsed'); 155 | if (!parsedFolder) { 156 | throw new Error('Failed to create parsed folder'); 157 | } 158 | 159 | // Get all files from the parsed CRX file 160 | const zip = parsed.zip; 161 | await Promise.all( 162 | Object.keys(zip.files).map(async (path) => { 163 | const zipObject = zip.files[path]; 164 | if (!zipObject.dir) { 165 | const content = await zipObject.async('uint8array'); 166 | parsedFolder.file(path, content); 167 | } 168 | }) 169 | ); 170 | 171 | // Generate and download the zip 172 | const blob = await newZip.generateAsync({ type: 'blob' }); 173 | const url = URL.createObjectURL(blob); 174 | const a = document.createElement('a'); 175 | a.href = url; 176 | // Use page title for the zip file name, fallback to 'extension-files' if no title 177 | const safeTitle = crxFile.pageTitle.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'extension-files'; 178 | a.download = `${safeTitle}.zip`; 179 | document.body.appendChild(a); 180 | a.click(); 181 | document.body.removeChild(a); 182 | URL.revokeObjectURL(url); 183 | 184 | showToast('All files downloaded successfully', 'success'); 185 | } catch (error) { 186 | console.error('Error downloading files:', error); 187 | showToast('Failed to download files', 'error'); 188 | } finally { 189 | setDownloadingAll(false); 190 | } 191 | } else if (pageData?.files.length) { 192 | try { 193 | setDownloadingAll(true); 194 | const latestVersions = groupedFiles.map(group => group.versions[0]); 195 | await SourceMapDownloader.downloadAllLatest(latestVersions, pageData.url, { 196 | onError: (error) => { 197 | showToast(error.message, 'error'); 198 | } 199 | }); 200 | showToast('All files downloaded successfully', 'success'); 201 | } catch (error) { 202 | console.error('Error downloading files:', error); 203 | showToast('Failed to download files', 'error'); 204 | } finally { 205 | setDownloadingAll(false); 206 | } 207 | } 208 | }; 209 | 210 | const showToast = (message: string, severity: 'success' | 'error' | 'info' | 'warning' = 'info') => { 211 | setToast({ 212 | open: true, 213 | message, 214 | severity 215 | }); 216 | }; 217 | 218 | const handleCloseToast = () => { 219 | setToast(prev => ({ ...prev, open: false })); 220 | }; 221 | 222 | const handleCrxFileDownload = async (path: string) => { 223 | if (!parsed) return; 224 | try { 225 | setDownloading(prev => ({ ...prev, [path]: true })); 226 | const file = parsed.zip.files[path]; 227 | if (!file) { 228 | throw new Error('File not found'); 229 | } 230 | const content = await file.async('blob'); 231 | const url = URL.createObjectURL(content); 232 | const a = document.createElement('a'); 233 | a.href = url; 234 | a.download = path.split('/').pop() || path; 235 | document.body.appendChild(a); 236 | a.click(); 237 | document.body.removeChild(a); 238 | URL.revokeObjectURL(url); 239 | showToast('File downloaded successfully', 'success'); 240 | } catch (error) { 241 | console.error('Error downloading file:', error); 242 | showToast('Failed to download file', 'error'); 243 | } finally { 244 | setDownloading(prev => ({ ...prev, [path]: false })); 245 | } 246 | }; 247 | 248 | const groupedFiles = useMemo(() => { 249 | if (!pageData?.files) return []; 250 | return groupSourceMapFiles(pageData.files).sort((a, b) => { 251 | const aFilename = a.url.split('/').pop() || ''; 252 | const bFilename = b.url.split('/').pop() || ''; 253 | return aFilename.localeCompare(bFilename); 254 | }); 255 | }, [pageData?.files]); 256 | 257 | const handleOpenDesktopApp = () => { 258 | // Use the existing openInDesktop function which handles fallback 259 | openInDesktop('handleOpenDesktopApp', serverStatus, { 260 | type: crxFile ? 'crx-files' : 'source-files', 261 | url: crxFile ? crxFile.crxUrl : pageData?.url 262 | }); 263 | }; 264 | 265 | if (loading) { 266 | return ( 267 | 268 | 269 | 270 | 271 | 272 | ); 273 | } 274 | 275 | return ( 276 | 284 | {/* Fixed Header */} 285 | 296 | 297 | 298 | {crxFile ? 'Extension Files' : 'Source Maps'} 299 | 300 | 301 | 302 | 307 | {serverStatus ? ( 308 | 309 | ) : ( 310 | 311 | )} 312 | 313 | 314 | {((groupedFiles.length > 0 && groupedFiles.map(g => g.versions[0]).reduce((sum, file) => sum + file.size, 0) > 0) || (crxFile && (parsed?.size || 0 + crxFile.size) > 0)) && ( 315 | g.versions[0]))})` 318 | }> 319 | 320 | 333 | 334 | 335 | )} 336 | 337 | 338 | 339 | 340 | 341 | browserAPI.runtime.openOptionsPage()}> 342 | 343 | 344 | 345 | 346 | 347 | 348 | {/* Scrollable Content */} 349 | 350 | {crxFile ? ( 351 | 356 | ) : groupedFiles.length > 0 ? ( 357 | 363 | ) : ( 364 | 365 | 366 | No {crxFile ? 'files' : 'source maps'} found on this page 367 | 368 | 369 | )} 370 | 371 | 372 | {/* Fixed Footer */} 373 | {stats && ( 374 | 384 | 385 | 386 | {`Storage Used: ${formatBytes(stats.usedSpace)}`} 387 | 388 | 389 | {stats.fileCount} Source Maps Found on {stats.uniqueSiteCount} {stats.uniqueSiteCount === 1 ? 'Site' : 'Sites'} 390 | 391 | 392 | 393 | )} 394 | 395 | 401 | 402 | ); 403 | } -------------------------------------------------------------------------------- /src/popup/components/CrxFileTree.tsx: -------------------------------------------------------------------------------- 1 | import { getFileIcon } from '@/components/fileIcon'; 2 | import { ParsedCrxFile } from '@/types'; 3 | import { formatBytes } from '@/utils/format'; 4 | import { 5 | ChevronRight, 6 | CloudDownload, 7 | ExpandMore, 8 | Folder, 9 | } from '@mui/icons-material'; 10 | import { Box, IconButton, Tooltip, Typography } from '@mui/material'; 11 | import { TreeItem as MuiTreeItem, SimpleTreeView as MuiTreeView } from '@mui/x-tree-view'; 12 | import JSZip from 'jszip'; 13 | import React, { useEffect, useState } from 'react'; 14 | import { LoadingScreen } from './LoadingScreen'; 15 | 16 | interface FileNode { 17 | name: string; 18 | path: string; 19 | size?: number; 20 | isDirectory?: boolean; 21 | children: { [key: string]: FileNode }; 22 | } 23 | 24 | interface Props { 25 | crxUrl: string; 26 | parsed: ParsedCrxFile | null; 27 | onDownload: (path: string) => void; 28 | } 29 | 30 | const TreeItem = MuiTreeItem as any; 31 | const TreeView = MuiTreeView as any; 32 | 33 | export function CrxFileTree({ crxUrl, parsed, onDownload }: Props) { 34 | const [expanded, setExpanded] = useState([]); 35 | const [fileTree, setFileTree] = useState(null); 36 | const [loading, setLoading] = useState(true); 37 | 38 | useEffect(() => { 39 | const loadFileTree = async () => { 40 | try { 41 | setLoading(true); 42 | if (parsed?.zip) { 43 | const tree = await buildFileTreeFromJszip(parsed.zip); 44 | setFileTree(tree); 45 | } 46 | } catch (error) { 47 | console.error('Error loading file tree:', error); 48 | } finally { 49 | setLoading(false); 50 | } 51 | }; 52 | loadFileTree(); 53 | }, [parsed]); 54 | 55 | const buildFileTreeFromJszip = async (jszip: JSZip): Promise => { 56 | const root: FileNode = { 57 | name: 'root', 58 | path: '', 59 | isDirectory: true, 60 | children: {}, 61 | }; 62 | 63 | // Process each file in the zip 64 | for (const [path, file] of Object.entries(jszip.files)) { 65 | if (file.dir) continue; // Skip directory entries as we'll create them implicitly 66 | 67 | // Split the path into segments 68 | const segments = path.split('/'); 69 | let currentNode = root; 70 | 71 | // Create/traverse the folder structure 72 | for (let i = 0; i < segments.length - 1; i++) { 73 | const segment = segments[i]; 74 | if (!currentNode.children[segment]) { 75 | console.log('create folder', segment, segments.slice(0, i + 1).join('/')) 76 | currentNode.children[segment] = { 77 | name: segment, 78 | path: segments.slice(0, i + 1).join('/'), 79 | isDirectory: true, 80 | children: {}, 81 | }; 82 | } 83 | currentNode = currentNode.children[segment]; 84 | } 85 | 86 | // Add the file to its parent folder 87 | const fileName = segments[segments.length - 1]; 88 | try { 89 | const fileData = await file.async('uint8array'); 90 | console.log('path', path, fileName, fileData.length) 91 | currentNode.children[fileName] = { 92 | name: fileName, 93 | path: path, 94 | size: fileData.length, 95 | isDirectory: false, 96 | children: {}, // Empty children for files 97 | }; 98 | } catch (error) { 99 | console.error(`Error processing file ${path}:`, error); 100 | console.log('create file- error ', fileName, path) 101 | currentNode.children[fileName] = { 102 | name: fileName, 103 | path: path, 104 | isDirectory: false, 105 | children: {}, 106 | }; 107 | } 108 | } 109 | 110 | return root; 111 | }; 112 | 113 | const renderTree = (node: FileNode, nodeId: string) => { 114 | const label = ( 115 | 116 | {node.isDirectory ? ( 117 | 118 | ) : ( 119 | getFileIcon(node.name) 120 | )} 121 | 122 | {node.name} 123 | 124 | {!node.isDirectory && ( 125 | { 128 | e.stopPropagation(); 129 | onDownload(node.path); 130 | }} 131 | sx={{ ml: 'auto' }} 132 | > 133 | 134 | 135 | )} 136 | 137 | ); 138 | 139 | return ( 140 | 145 | {Object.entries(node.children).map(([childName, childNode]) => 146 | renderTree(childNode, `${node.path || nodeId}-${childName}`) 147 | )} 148 | 149 | ); 150 | }; 151 | 152 | const handleToggle = (_event: React.SyntheticEvent, nodeIds: string[]) => { 153 | setExpanded(nodeIds); 154 | }; 155 | 156 | if (loading) { 157 | return 158 | } 159 | 160 | if (!fileTree) { 161 | return 162 | 163 | 164 | No files found on this page 165 | 166 | 167 | 168 | } 169 | 170 | return ( 171 | 172 | } 174 | defaultExpandIcon={} 175 | expanded={expanded} 176 | onNodeToggle={handleToggle} 177 | sx={{ flexGrow: 1 }} 178 | > 179 | {Object.entries(fileTree.children).map(([name, node]) => 180 | renderTree(node, name) 181 | )} 182 | 183 | 184 | ); 185 | } -------------------------------------------------------------------------------- /src/popup/components/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import { Box, CircularProgress } from '@mui/material'; 2 | 3 | export function LoadingScreen() { 4 | return ( 5 | 12 | 13 | 14 | ); 15 | } -------------------------------------------------------------------------------- /src/popup/components/SourceMapTable.tsx: -------------------------------------------------------------------------------- 1 | import { getFileIcon } from '@/components/fileIcon'; 2 | import { SourceMapFile } from '@/types'; 3 | import { formatBytes } from '@/utils/format'; 4 | import { GroupedSourceMapFile } from '@/utils/sourceMapUtils'; 5 | import { CloudDownload as CloudDownloadIcon, History as HistoryIcon } from '@mui/icons-material'; 6 | import { 7 | Box, 8 | CircularProgress, 9 | IconButton, 10 | Paper, 11 | Table, 12 | TableBody, 13 | TableCell, 14 | TableContainer, 15 | TableHead, 16 | TableRow, 17 | Tooltip, 18 | Typography 19 | } from '@mui/material'; 20 | 21 | interface Props { 22 | groupedFiles: GroupedSourceMapFile[]; 23 | onDownload: (file: SourceMapFile) => void; 24 | onVersionMenuOpen: (groupUrl: string) => void; 25 | downloading: { [key: string]: boolean }; 26 | } 27 | 28 | export function SourceMapTable({ groupedFiles, onDownload, onVersionMenuOpen, downloading }: Props) { 29 | return ( 30 | 38 | 54 | 62 | 63 | Source File 64 | Latest Version 65 | Previous Versions 66 | 67 | 68 | 69 | {groupedFiles.map((group) => ( 70 | 71 | 76 | 77 | 80 | {getFileIcon(group.url.split('/').pop())} 81 | 91 | {group.url.split('/').pop()} 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | onDownload(group.versions[0])} 103 | disabled={downloading[group.versions[0].id]} 104 | > 105 | {downloading[group.versions[0].id] ? ( 106 | 107 | ) : ( 108 | 109 | )} 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | {group.versions.length > 1 && ( 118 | 119 | 120 | onVersionMenuOpen(group.url)} 123 | > 124 | 125 | 126 | 127 | 128 | )} 129 | 130 | 131 | 132 | ))} 133 | 134 |
135 |
136 | ); 137 | } -------------------------------------------------------------------------------- /src/popup/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Source Detector 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/popup/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { ThemeProvider, CssBaseline } from '@mui/material'; 4 | import { useAppTheme } from '@/theme'; 5 | import App from './App'; 6 | 7 | function Root() { 8 | const theme = useAppTheme(); 9 | 10 | return ( 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | createRoot(document.getElementById('root')!).render( 19 | 20 | 21 | 22 | ); -------------------------------------------------------------------------------- /src/storage/database.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_SETTINGS } from '@/background/constants'; 2 | import { AppSettings, Page, PageSourceMap, SourceMapFile, CrxFile, SyncStatus } from '@/types'; 3 | import Dexie from 'dexie'; 4 | 5 | const DB_VERSION = 1; 6 | const DB_NAME = 'SourceDetectorDB'; 7 | 8 | export class SourceDetectorDB extends Dexie { 9 | sourceMapFiles!: Dexie.Table; 10 | pages!: Dexie.Table; 11 | pageSourceMaps!: Dexie.Table; 12 | settings!: Dexie.Table; 13 | crxFiles!: Dexie.Table; 14 | syncStatus!: Dexie.Table; 15 | 16 | constructor() { 17 | super(DB_NAME); 18 | 19 | this.version(DB_VERSION).stores({ 20 | sourceMapFiles: '++id, url, timestamp, fileType, isLatest, hash, size', 21 | pages: '++id, url, timestamp', 22 | pageSourceMaps: '++id, pageId, sourceMapId, timestamp', 23 | settings: 'id', 24 | crxFiles: '++id, pageUrl, pageTitle, crxUrl, blob, size, timestamp, count, contentHash', 25 | syncStatus: 'tableName' 26 | }); 27 | } 28 | 29 | async addPage(page: Omit): Promise { 30 | const id = await this.pages.add(page as any); 31 | return { ...page, id }; 32 | } 33 | 34 | async addPageSourceMap(relation: Omit): Promise { 35 | const id = await this.pageSourceMaps.add(relation as any); 36 | return { ...relation, id }; 37 | } 38 | 39 | async addSourceMapFile(file: Omit): Promise { 40 | const id = await this.sourceMapFiles.add(file as any); 41 | return { ...file, id }; 42 | } 43 | 44 | async addCrxFile(file: Omit): Promise { 45 | const id = await this.crxFiles.add(file as any); 46 | return { ...file, id }; 47 | } 48 | 49 | async updateCrxFile(crxFile: CrxFile): Promise { 50 | await this.crxFiles.put(crxFile); 51 | } 52 | 53 | async getCrxFileByPageUrl(pageUrl: string): Promise { 54 | return this.crxFiles 55 | .where('pageUrl') 56 | .equals(pageUrl) 57 | .first(); 58 | } 59 | 60 | async getSettings(): Promise { 61 | try { 62 | const settings = await this.settings.toArray(); 63 | if (settings.length === 0) { 64 | await this.settings.add(DEFAULT_SETTINGS); 65 | return DEFAULT_SETTINGS; 66 | } 67 | return settings[0]; 68 | } catch (error) { 69 | console.error('Error in getSettings:', error); 70 | throw error; 71 | } 72 | } 73 | 74 | async updateSettings(settings: Partial): Promise { 75 | try { 76 | const currentSettings = await this.getSettings(); 77 | const updatedSettings = { 78 | ...currentSettings, 79 | ...settings, 80 | id: 'settings' 81 | }; 82 | await this.settings.put(updatedSettings); 83 | } catch (error) { 84 | console.error('Error in updateSettings:', error); 85 | throw error; 86 | } 87 | } 88 | 89 | async getStorageStats() { 90 | // Run all queries in parallel for better performance 91 | let totalSize = 0; 92 | let oldestTimestamp = Date.now(); 93 | 94 | // Count unique sites using index, processing one record at a time 95 | const uniqueSites = new Set(); 96 | 97 | const [fileCount, pagesCount] = await Promise.all([ 98 | 99 | // Get file count without loading data 100 | this.sourceMapFiles.count(), 101 | 102 | // Get page count without loading data 103 | this.pages.count(), 104 | 105 | // Get total size and oldest file in a single table scan 106 | this.sourceMapFiles.each((file: SourceMapFile) => { 107 | totalSize += file.size; 108 | if (file.timestamp < oldestTimestamp) { 109 | oldestTimestamp = file.timestamp; 110 | } 111 | }), 112 | 113 | this.pages 114 | .orderBy('url') 115 | .each(page => { 116 | try { 117 | uniqueSites.add(new URL(page.url).hostname); 118 | } catch { 119 | uniqueSites.add(page.url); 120 | } 121 | }) 122 | ]); 123 | 124 | return { 125 | usedSpace: totalSize, 126 | totalSize: totalSize, 127 | fileCount, 128 | uniqueSiteCount: uniqueSites.size, 129 | pagesCount, 130 | oldestTimestamp: oldestTimestamp 131 | }; 132 | } 133 | 134 | async getPageFiles(pageUrl: string): Promise { 135 | const page = await this.pages.where('url').equals(pageUrl).first(); 136 | if (!page) return []; 137 | 138 | const pageSourceMaps = await this.pageSourceMaps 139 | .where('pageId') 140 | .equals(page.id) 141 | .toArray(); 142 | 143 | const sourceMapIds = pageSourceMaps.map(psm => psm.sourceMapId); 144 | return await this.sourceMapFiles 145 | .where('id') 146 | .anyOf(sourceMapIds) 147 | .toArray(); 148 | } 149 | 150 | async addSourceMapToPage(pageUrl: string, pageTitle: string, sourceMap: SourceMapFile): Promise { 151 | // Get or create page 152 | let page = await this.pages.where('url').equals(pageUrl).first(); 153 | 154 | if (!page) { 155 | page = await this.addPage({ 156 | url: pageUrl, 157 | title: pageTitle, 158 | timestamp: Date.now() 159 | }); 160 | } 161 | 162 | // Check for existing relation 163 | const existingRelation = await this.pageSourceMaps 164 | .where('pageId').equals(page.id) 165 | .and(psm => psm.sourceMapId === sourceMap.id) 166 | .first(); 167 | if (existingRelation) { 168 | return; 169 | } 170 | 171 | // Create page-sourcemap relation 172 | await this.addPageSourceMap({ 173 | pageId: page.id, 174 | sourceMapId: sourceMap.id, 175 | timestamp: Date.now() 176 | }); 177 | } 178 | 179 | // Sync status methods 180 | async getLastSyncId(table: string): Promise { 181 | const status = await this.syncStatus.get(table); 182 | return status?.lastId || 0; 183 | } 184 | 185 | async updateLastSyncId(table: string, id: number): Promise { 186 | await this.syncStatus.put({ tableName: table, lastId: id }); 187 | } 188 | 189 | async getModifiedData(table: string, lastId: number, chunkSize: number): Promise { 190 | const tableMap = { 191 | sourceMapFiles: this.sourceMapFiles, 192 | pages: this.pages, 193 | pageSourceMaps: this.pageSourceMaps, 194 | crxFiles: this.crxFiles 195 | }; 196 | 197 | const dbTable = tableMap[table as keyof typeof tableMap]; 198 | if (!dbTable) return []; 199 | 200 | return await dbTable 201 | .where('id') 202 | .above(lastId) 203 | .limit(chunkSize) 204 | .toArray(); 205 | } 206 | } -------------------------------------------------------------------------------- /src/theme/index.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from '@mui/material/styles'; 2 | import { useEffect, useState } from 'react'; 3 | import { MESSAGE_TYPES } from '@/background/constants'; 4 | 5 | export function useAppTheme() { 6 | const [darkMode, setDarkMode] = useState(false); 7 | 8 | useEffect(() => { 9 | // Load theme setting 10 | chrome.runtime.sendMessage({ 11 | type: MESSAGE_TYPES.GET_SETTINGS 12 | }).then(response => { 13 | if (response.success) { 14 | setDarkMode(response.data.darkMode); 15 | } 16 | }); 17 | }, []); 18 | 19 | return createTheme({ 20 | palette: { 21 | mode: darkMode ? 'dark' : 'light', 22 | primary: { 23 | main: '#1976d2', 24 | }, 25 | secondary: { 26 | main: '#dc004e', 27 | }, 28 | }, 29 | components: { 30 | MuiButton: { 31 | styleOverrides: { 32 | root: { 33 | textTransform: 'none', 34 | }, 35 | }, 36 | }, 37 | MuiAppBar: { 38 | styleOverrides: { 39 | root: { 40 | boxShadow: 'none', 41 | borderBottom: '1px solid rgba(0, 0, 0, 0.12)', 42 | }, 43 | }, 44 | }, 45 | }, 46 | }); 47 | } -------------------------------------------------------------------------------- /src/types/chrome.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare namespace chrome { 4 | export interface Runtime { 5 | sendMessage: ( 6 | message: any, 7 | responseCallback?: (response: T) => void 8 | ) => void; 9 | onMessage: { 10 | addListener: ( 11 | callback: ( 12 | message: any, 13 | sender: chrome.runtime.MessageSender, 14 | sendResponse: (response?: any) => void 15 | ) => void | boolean 16 | ) => void; 17 | }; 18 | } 19 | 20 | export interface Tabs { 21 | query: (queryInfo: { 22 | active: boolean; 23 | currentWindow: boolean; 24 | }) => Promise; 25 | } 26 | 27 | export interface Storage { 28 | local: { 29 | get: (keys?: string | string[] | null) => Promise; 30 | set: (items: { [key: string]: any }) => Promise; 31 | remove: (keys: string | string[]) => Promise; 32 | clear: () => Promise; 33 | }; 34 | } 35 | } -------------------------------------------------------------------------------- /src/types/env.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module '*.scss'; 4 | declare module '*.svg'; 5 | declare module '*.png'; 6 | declare module '*.jpg'; 7 | declare module '*.jpeg'; 8 | declare module '*.gif'; 9 | declare module '*.bmp'; 10 | declare module '*.tiff'; 11 | declare module '*.json' { 12 | const value: any; 13 | export default value; 14 | } -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip'; 2 | 3 | export interface Page { 4 | id: number; 5 | url: string; 6 | title: string; 7 | timestamp: number; 8 | } 9 | 10 | export interface PageSourceMap { 11 | id: number; 12 | pageId: number; 13 | sourceMapId: number; 14 | timestamp: number; 15 | } 16 | 17 | export interface SourceMapFile { 18 | id: number; 19 | url: string; 20 | sourceMapUrl: string; 21 | content: string; 22 | originalContent: string; 23 | fileType: 'js' | 'css'; 24 | size: number; 25 | timestamp: number; 26 | version: number; 27 | hash: string; 28 | isLatest: boolean; 29 | } 30 | 31 | export interface SourceMapFoundData { 32 | pageTitle: string; 33 | pageUrl: string; 34 | sourceUrl: string; 35 | mapUrl: string; 36 | fileType: 'js' | 'css'; 37 | originalContent: string; 38 | } 39 | 40 | export interface PageData { 41 | url: string; 42 | title: string; 43 | timestamp: number; 44 | files: SourceMapFile[]; 45 | } 46 | 47 | export interface StorageStats { 48 | usedSpace: number; 49 | totalSize: number; 50 | fileCount: number; 51 | uniqueSiteCount: number; 52 | pagesCount: number; 53 | oldestTimestamp: number; 54 | } 55 | 56 | export interface AppSettings { 57 | id: string; 58 | cleanupThreshold: number; 59 | } 60 | 61 | export interface Message { 62 | type: string; 63 | payload: T; 64 | } 65 | 66 | export interface CrxFile { 67 | id: number; 68 | pageUrl: string; 69 | pageTitle: string; 70 | crxUrl: string; 71 | blob: Blob; 72 | size: number; 73 | timestamp: number; 74 | count: number; 75 | contentHash: string; 76 | } 77 | 78 | export interface ParsedCrxFile { 79 | zip: JSZip; 80 | size: number; 81 | count: number; 82 | timestamp: number; 83 | blob: Blob; 84 | } 85 | 86 | export interface SyncStatus { 87 | tableName: string; 88 | lastId: number; 89 | } -------------------------------------------------------------------------------- /src/utils/browser-polyfill.ts: -------------------------------------------------------------------------------- 1 | // Create a browser API compatibility layer 2 | import browser from 'webextension-polyfill'; 3 | 4 | export const browserAPI = { 5 | action: browser.action, 6 | runtime: browser.runtime, 7 | tabs: browser.tabs, 8 | webRequest: browser.webRequest, 9 | windows: browser.windows 10 | }; 11 | 12 | // Helper types for better TypeScript support 13 | export type BrowserStorage = typeof browser.storage; 14 | export type BrowserRuntime = typeof browser.runtime; 15 | export type BrowserTabs = typeof browser.tabs; 16 | export type BrowserAction = typeof browser.action; 17 | export type BrowserWebRequest = typeof browser.webRequest; -------------------------------------------------------------------------------- /src/utils/crx-to-zip.ts: -------------------------------------------------------------------------------- 1 | import CryptoJS from 'crypto-js'; 2 | 3 | /** 4 | * Strips CRX headers from zip 5 | * @param arraybuffer - CRX file data 6 | * @returns Promise that resolves with the ZIP blob and public key 7 | */ 8 | export function crxToZip(arraybuffer: ArrayBuffer): Promise<{ zip: Blob; publicKey: string | undefined }> { 9 | return new Promise((resolve, reject) => { 10 | // Definition of crx format: http://developer.chrome.com/extensions/crx.html 11 | const view = new Uint8Array(arraybuffer); 12 | 13 | // 50 4b 03 04 14 | if (view[0] === 80 && view[1] === 75 && view[2] === 3 && view[3] === 4) { 15 | console.warn('Input is not a CRX file, but a ZIP file.'); 16 | resolve({ zip: new Blob([arraybuffer], { type: 'application/zip' }), publicKey: undefined }); 17 | return; 18 | } 19 | 20 | // 43 72 32 34 21 | if (view[0] !== 67 || view[1] !== 114 || view[2] !== 50 || view[3] !== 52) { 22 | if (isMaybeZipData(view)) { 23 | console.warn('Input is not a CRX file, but possibly a ZIP file.'); 24 | resolve({ zip: new Blob([arraybuffer], { type: 'application/zip' }), publicKey: undefined }); 25 | return; 26 | } 27 | reject(new Error('Invalid header: Does not start with Cr24.')); 28 | return; 29 | } 30 | 31 | // 02 00 00 00 32 | // 03 00 00 00 CRX3 33 | if (view[4] !== 2 && view[4] !== 3 || view[5] || view[6] || view[7]) { 34 | reject(new Error('Unexpected crx format version number.')); 35 | return; 36 | } 37 | 38 | let zipStartOffset: number; 39 | let publicKeyBase64: string | undefined; 40 | 41 | if (view[4] === 2) { 42 | const publicKeyLength = calcLength(view[8], view[9], view[10], view[11]); 43 | const signatureLength = calcLength(view[12], view[13], view[14], view[15]); 44 | // 16 = Magic number (4), CRX format version (4), lengths (2x4) 45 | zipStartOffset = 16 + publicKeyLength + signatureLength; 46 | 47 | // Public key 48 | publicKeyBase64 = btoa(getBinaryString(view, 16, 16 + publicKeyLength)); 49 | } else { // view[4] === 3 50 | // CRX3 - https://cs.chromium.org/chromium/src/components/crx_file/crx3.proto 51 | const crx3HeaderLength = calcLength(view[8], view[9], view[10], view[11]); 52 | // 12 = Magic number (4), CRX format version (4), header length (4) 53 | zipStartOffset = 12 + crx3HeaderLength; 54 | 55 | // Public key 56 | publicKeyBase64 = getPublicKeyFromProtoBuf(view, 12, zipStartOffset); 57 | } 58 | 59 | // addons.opera.com creates CRX3 files by prepending the CRX3 header to the CRX2 data. 60 | if ( 61 | // CRX3 62 | view[4] === 3 && 63 | // 43 72 32 34 - Cr24 = CRX magic 64 | view[zipStartOffset + 0] === 67 && 65 | view[zipStartOffset + 1] === 114 && 66 | view[zipStartOffset + 2] === 50 && 67 | view[zipStartOffset + 3] === 52 68 | ) { 69 | console.warn('Nested CRX: Expected zip data, but found another CRX file instead.'); 70 | return crxToZip(arraybuffer.slice(zipStartOffset)) 71 | .then(({ zip, publicKey: nestedKey }) => { 72 | if (publicKeyBase64 !== nestedKey) { 73 | console.warn('Nested CRX: pubkey mismatch; found ' + nestedKey); 74 | } 75 | resolve({ zip, publicKey: publicKeyBase64 }); 76 | }) 77 | .catch(reject); 78 | } 79 | 80 | // Create a new view for the existing buffer, and wrap it in a Blob object. 81 | const zipFragment = new Blob([ 82 | new Uint8Array(arraybuffer, zipStartOffset) 83 | ], { 84 | type: 'application/zip' 85 | }); 86 | resolve({ zip: zipFragment, publicKey: publicKeyBase64 }); 87 | }); 88 | } 89 | 90 | function calcLength(a: number, b: number, c: number, d: number): number { 91 | let length = 0; 92 | length += a << 0; 93 | length += b << 8; 94 | length += c << 16; 95 | length += d << 24 >>> 0; 96 | return length; 97 | } 98 | 99 | function getBinaryString(bytesView: Uint8Array, startOffset: number, endOffset: number): string { 100 | let binaryString = ''; 101 | for (let i = startOffset; i < endOffset; ++i) { 102 | binaryString += String.fromCharCode(bytesView[i]); 103 | } 104 | return binaryString; 105 | } 106 | 107 | function getPublicKeyFromProtoBuf(bytesView: Uint8Array, startOffset: number, endOffset: number): string | undefined { 108 | function getvarint(): number { 109 | let val = bytesView[startOffset] & 0x7F; 110 | if (bytesView[startOffset++] < 0x80) return val; 111 | val |= (bytesView[startOffset] & 0x7F) << 7; 112 | if (bytesView[startOffset++] < 0x80) return val; 113 | val |= (bytesView[startOffset] & 0x7F) << 14; 114 | if (bytesView[startOffset++] < 0x80) return val; 115 | val |= (bytesView[startOffset] & 0x7F) << 21; 116 | if (bytesView[startOffset++] < 0x80) return val; 117 | val = (val | (bytesView[startOffset] & 0xF) << 28) >>> 0; 118 | if (bytesView[startOffset++] & 0x80) console.warn('proto: not a uint32'); 119 | return val; 120 | } 121 | 122 | const publicKeys: string[] = []; 123 | let crxIdBin: Uint8Array | undefined; 124 | 125 | while (startOffset < endOffset) { 126 | const key = getvarint(); 127 | const length = getvarint(); 128 | if (key === 80002) { // This is ((10000 << 3) | 2) (signed_header_data). 129 | const sigdatakey = getvarint(); 130 | const sigdatalen = getvarint(); 131 | if (sigdatakey !== 0xA) { 132 | console.warn('proto: Unexpected key in signed_header_data: ' + sigdatakey); 133 | } else if (sigdatalen !== 16) { 134 | console.warn('proto: Unexpected signed_header_data length ' + length); 135 | } else if (crxIdBin) { 136 | console.warn('proto: Unexpected duplicate signed_header_data'); 137 | } else { 138 | crxIdBin = bytesView.subarray(startOffset, startOffset + 16); 139 | } 140 | startOffset += sigdatalen; 141 | continue; 142 | } 143 | if (key !== 0x12) { 144 | // Likely 0x1a (sha256_with_ecdsa). 145 | if (key !== 0x1a) { 146 | console.warn('proto: Unexpected key: ' + key); 147 | } 148 | startOffset += length; 149 | continue; 150 | } 151 | // Found 0x12 (sha256_with_rsa); Look for 0xA (public_key). 152 | const keyproofend = startOffset + length; 153 | let keyproofkey = getvarint(); 154 | let keyprooflength = getvarint(); 155 | // AsymmetricKeyProof could contain 0xA (public_key) or 0x12 (signature). 156 | if (keyproofkey === 0x12) { 157 | startOffset += keyprooflength; 158 | if (startOffset >= keyproofend) { 159 | // signature without public_key...? The protocol definition allows it... 160 | continue; 161 | } 162 | keyproofkey = getvarint(); 163 | keyprooflength = getvarint(); 164 | } 165 | if (keyproofkey !== 0xA) { 166 | startOffset += keyprooflength; 167 | console.warn('proto: Unexpected key in AsymmetricKeyProof: ' + keyproofkey); 168 | continue; 169 | } 170 | if (startOffset + keyprooflength > endOffset) { 171 | console.warn('proto: size of public_key field is too large'); 172 | break; 173 | } 174 | // Found 0xA (public_key). 175 | publicKeys.push(getBinaryString(bytesView, startOffset, startOffset + keyprooflength)); 176 | startOffset = keyproofend; 177 | } 178 | 179 | if (!publicKeys.length) { 180 | console.warn('proto: Did not find any public key'); 181 | return undefined; 182 | } 183 | if (!crxIdBin) { 184 | console.warn('proto: Did not find crx_id'); 185 | return undefined; 186 | } 187 | 188 | const crxIdHex = CryptoJS.enc.Latin1.parse(getBinaryString(crxIdBin, 0, 16)).toString(); 189 | for (const publicKey of publicKeys) { 190 | const sha256sum = CryptoJS.SHA256(CryptoJS.enc.Latin1.parse(publicKey)).toString(); 191 | if (sha256sum.slice(0, 32) === crxIdHex) { 192 | return btoa(publicKey); 193 | } 194 | } 195 | console.warn('proto: None of the public keys matched with crx_id'); 196 | return undefined; 197 | } 198 | 199 | function isMaybeZipData(view: Uint8Array): boolean { 200 | // Find EOCD (0xFFFF is the maximum size of an optional trailing comment). 201 | for (let i = view.length - 22, ii = Math.max(0, i - 0xFFFF); i >= ii; --i) { 202 | if (view[i] === 0x50 && view[i + 1] === 0x4b && 203 | view[i + 2] === 0x05 && view[i + 3] === 0x06) { 204 | return true; 205 | } 206 | } 207 | return false; 208 | } -------------------------------------------------------------------------------- /src/utils/desktopApp.ts: -------------------------------------------------------------------------------- 1 | import { browserAPI } from '@/utils/browser-polyfill'; 2 | 3 | // Protocol name for the Source Detector desktop app 4 | // This needs to be registered in the desktop app for each operating system 5 | const PROTOCOL = 'source-detector://'; 6 | 7 | export type DesktopAction = 8 | | 'handleVersionMenuOpen' 9 | | 'handleViewAllPages' 10 | | 'handleOpenDesktopApp'; 11 | 12 | const getDesktopAppUrl = (type: DesktopAction, options?: any) => { 13 | let desktopUrl = ''; 14 | switch (type) { 15 | case 'handleVersionMenuOpen': 16 | desktopUrl = `${PROTOCOL}source-files?url=${encodeURIComponent(options.url)}`; 17 | break; 18 | case 'handleViewAllPages': 19 | desktopUrl = `${PROTOCOL}`; 20 | break; 21 | case 'handleOpenDesktopApp': 22 | const { type, url } = options; 23 | desktopUrl = `${PROTOCOL}${type}?url=${encodeURIComponent(url)}`; 24 | break; 25 | } 26 | return desktopUrl; 27 | } 28 | 29 | export async function openInDesktop(type: DesktopAction, serverStatus: boolean, options: object) { 30 | try { 31 | // Try to open in desktop app first 32 | try { 33 | // Create the desktop app URL 34 | const desktopUrl = getDesktopAppUrl(type, options); 35 | 36 | // Try to open the desktop app 37 | window.open(desktopUrl, '_self'); 38 | if (serverStatus) { 39 | return; 40 | } 41 | 42 | // Set a timeout to check if the desktop app opened 43 | setTimeout(() => { 44 | // If we're still here after a short delay, the desktop app probably didn't open 45 | openWebVersion(type, options); 46 | }, 200); 47 | } catch (error) { 48 | console.error('Failed to open desktop app:', error); 49 | openWebVersion(type, options); 50 | } 51 | } catch (error) { 52 | console.error('Error checking desktop app settings:', error); 53 | openWebVersion(type, options); 54 | } 55 | } 56 | 57 | function openWebVersion(type: string, options?: any) { 58 | browserAPI.tabs.create({ 59 | url: browserAPI.runtime.getURL(`pages/desktop/index.html?type=${type}${options ? `&options=${encodeURIComponent(JSON.stringify(options))}` : ''}`) 60 | }); 61 | } -------------------------------------------------------------------------------- /src/utils/format.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Format bytes to a readable string 3 | */ 4 | export function formatBytes(bytes: number): string { 5 | if (bytes === 0) return '0 B'; 6 | 7 | const k = 1024; 8 | const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; 9 | const i = Math.floor(Math.log(bytes) / Math.log(k)); 10 | 11 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/isExtensionPage.ts: -------------------------------------------------------------------------------- 1 | export function isExtensionPage(url: string): boolean { 2 | return url.startsWith('https://chrome.google.com/webstore/detail/') || 3 | url.startsWith('https://chromewebstore.google.com/detail/') || 4 | url.startsWith('chrome-extension://') || 5 | url.startsWith('moz-extension://') || // Firefox 6 | url.startsWith('edge-extension://'); // Edge 7 | } -------------------------------------------------------------------------------- /src/utils/parseCrxFile.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip'; 2 | import { crxToZip } from './crx-to-zip'; 3 | import { ParsedCrxFile, CrxFile } from '../types'; 4 | 5 | const CACHE_EXPIRATION = 5000; // 5 seconds 6 | const cache = new Map(); 7 | 8 | export async function parsedCrxFileFromCrxFile(crxFile: CrxFile): Promise { 9 | const blob = crxFile.blob; 10 | return parsedCrxFileFromBlob(blob, crxFile.timestamp); 11 | } 12 | 13 | export async function parsedCrxFileFromBlob(blob: Blob, timestamp: number): Promise { 14 | console.log('blob', blob, typeof blob); 15 | const buffer = await blob.arrayBuffer(); 16 | console.log('buffer', buffer); 17 | // Convert CRX to ZIP 18 | const { zip } = await crxToZip(buffer); 19 | console.log('zip', zip); 20 | const jszip = await JSZip.loadAsync(zip); 21 | console.log('jszip', jszip); 22 | const parsedFileCount = Object.values(jszip.files).filter(file => !file.dir).length; 23 | const size = zip.size; 24 | return { 25 | zip: jszip, 26 | count: parsedFileCount, 27 | timestamp, 28 | blob, 29 | size 30 | }; 31 | } 32 | 33 | export async function parsedCrxFileFromUrl(crxUrl: string): Promise { 34 | try { 35 | console.log('parseCrxFile', crxUrl); 36 | const cached = cache.get(crxUrl); 37 | console.log('cached', cached); 38 | if (cached && (Date.now() - cached.timestamp) < CACHE_EXPIRATION) { 39 | console.log('cached', cached); 40 | return cached; 41 | } 42 | 43 | const response = await fetch(crxUrl); 44 | console.log('response', response); 45 | const blob = await response.blob(); 46 | 47 | const result = await parsedCrxFileFromBlob(blob, Date.now()); 48 | 49 | if (result) { 50 | cache.set(crxUrl, result); 51 | } 52 | 53 | return result; 54 | } catch (error) { 55 | console.error('Error parsing CRX file:', error); 56 | return null; 57 | } 58 | } 59 | 60 | // Parse CRX file and extract source maps 61 | export async function parseCrxFile(crxUrl: string): Promise { 62 | if (typeof crxUrl === 'object') { 63 | return parsedCrxFileFromCrxFile(crxUrl); 64 | } else { 65 | return parsedCrxFileFromUrl(crxUrl); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/utils/sourceMapDownloader.ts: -------------------------------------------------------------------------------- 1 | import JSZip from 'jszip'; 2 | import { SourceMapConsumer } from 'source-map-js'; 3 | import { SourceMapFile } from '@/types'; 4 | 5 | interface DownloadOptions { 6 | onError?: (error: Error) => void; 7 | } 8 | 9 | export class SourceMapDownloader { 10 | private static async createZipWithSourceMap( 11 | file: SourceMapFile, 12 | _zip: JSZip, 13 | compiledFolder: JSZip, 14 | sourceFolder: JSZip 15 | ) { 16 | // Parse the source map 17 | const rawSourceMap = JSON.parse(file.content); 18 | const consumer = new SourceMapConsumer(rawSourceMap); 19 | 20 | // Get full path including domain for compiled file 21 | const originalUrl = new URL(file.url); 22 | const compiledPath = `${originalUrl.hostname}${originalUrl.pathname}`; 23 | 24 | // Add original file and source map maintaining the full path structure 25 | compiledFolder.file(compiledPath, file.originalContent); 26 | compiledFolder.file(`${compiledPath}.map`, file.content); 27 | 28 | // Process source files maintaining their relative paths 29 | const processedPaths = new Set(); 30 | consumer.sources.forEach((sourcePath) => { 31 | if (processedPaths.has(sourcePath)) return; 32 | processedPaths.add(sourcePath); 33 | 34 | const sourceContent = consumer.sourceContentFor(sourcePath); 35 | if (sourceContent) { 36 | // Clean up source path (remove leading slash and any '../' or './') 37 | const cleanPath = sourcePath 38 | .replace(/^\//, '') // Remove leading / 39 | .replace(/^(\.\.\/)*/, '') // Remove leading ../ 40 | .replace(/^(\.\/)*/, ''); // Remove leading ./ 41 | 42 | // Add to source folder with full path structure 43 | sourceFolder.file(cleanPath, sourceContent); 44 | } 45 | }); 46 | 47 | return { compiledPath, originalUrl }; 48 | } 49 | 50 | private static async downloadZip(zip: JSZip, fileName: string) { 51 | const zipBlob = await zip.generateAsync({ type: "blob" }); 52 | const url = URL.createObjectURL(zipBlob); 53 | const a = document.createElement('a'); 54 | a.href = url; 55 | a.download = fileName; 56 | document.body.appendChild(a); 57 | a.click(); 58 | document.body.removeChild(a); 59 | URL.revokeObjectURL(url); 60 | } 61 | 62 | static async downloadSingle(file: SourceMapFile, options?: DownloadOptions) { 63 | try { 64 | const zip = new JSZip(); 65 | const compiledFolder = zip.folder("compiled"); 66 | const sourceFolder = zip.folder("src"); 67 | 68 | if (!compiledFolder || !sourceFolder) { 69 | throw new Error('Failed to create folders'); 70 | } 71 | 72 | const { compiledPath, originalUrl } = await this.createZipWithSourceMap( 73 | file, 74 | zip, 75 | compiledFolder, 76 | sourceFolder 77 | ); 78 | 79 | const domainName = originalUrl.hostname.replace(/[^a-zA-Z0-9]/g, '_'); 80 | const fileName = `${domainName}_${compiledPath.split('/').pop()}_v${file.version}_with_sources.zip`; 81 | 82 | await this.downloadZip(zip, fileName); 83 | } catch (error) { 84 | console.error('Error downloading source map:', error); 85 | options?.onError?.(error as Error); 86 | } 87 | } 88 | 89 | static async downloadAllLatest(files: SourceMapFile[], pageUrl: string, options?: DownloadOptions) { 90 | try { 91 | const zip = new JSZip(); 92 | const compiledFolder = zip.folder("compiled"); 93 | const sourceFolder = zip.folder("src"); 94 | 95 | if (!compiledFolder || !sourceFolder) { 96 | throw new Error('Failed to create folders'); 97 | } 98 | 99 | // Process each file 100 | for (const file of files) { 101 | try { 102 | await this.createZipWithSourceMap( 103 | file, 104 | zip, 105 | compiledFolder, 106 | sourceFolder 107 | ); 108 | } catch (error) { 109 | console.error(`Error processing file ${file.url}:`, error); 110 | // Continue with other files 111 | } 112 | } 113 | 114 | const domainName = new URL(pageUrl).hostname.replace(/[^a-zA-Z0-9]/g, '_'); 115 | const fileName = `${domainName}_all_latest_source_maps.zip`; 116 | 117 | await this.downloadZip(zip, fileName); 118 | } catch (error) { 119 | console.error('Error downloading source maps:', error); 120 | options?.onError?.(error as Error); 121 | } 122 | } 123 | } -------------------------------------------------------------------------------- /src/utils/sourceMapUtils.ts: -------------------------------------------------------------------------------- 1 | import { SourceMapFile } from '@/types'; 2 | 3 | export interface GroupedSourceMapFile { 4 | url: string; 5 | fileType: 'js' | 'css'; 6 | versions: SourceMapFile[]; 7 | } 8 | 9 | export function groupSourceMapFiles(files: SourceMapFile[]): GroupedSourceMapFile[] { 10 | const groups: { [key: string]: SourceMapFile[] } = {}; 11 | 12 | // Group files by URL 13 | files.forEach(file => { 14 | if (!groups[file.url]) { 15 | groups[file.url] = []; 16 | } 17 | groups[file.url].push(file); 18 | }); 19 | 20 | // Convert groups to array and sort versions 21 | return Object.entries(groups).map(([url, files]) => ({ 22 | url, 23 | fileType: files[0].fileType, 24 | versions: files.sort((a, b) => b.version - a.version) // Sort by version descending 25 | })); 26 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": [ 6 | "es6", 7 | "dom", 8 | "ES2020", 9 | "DOM", 10 | "DOM.Iterable" 11 | ], 12 | "module": "ESNext", 13 | "moduleResolution": "bundler", 14 | "allowImportingTsExtensions": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "strict": true, 20 | "skipLibCheck": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "allowSyntheticDefaultImports": true, 25 | "baseUrl": ".", 26 | "paths": { 27 | "@/*": [ 28 | "./src/*" 29 | ] 30 | }, 31 | "types": [ 32 | "chrome", 33 | "node", 34 | "react", 35 | "webextension-polyfill" 36 | ] 37 | }, 38 | "include": [ 39 | "src/**/*", 40 | "scripts/**/*" 41 | ] 42 | } --------------------------------------------------------------------------------